文件是 Nuxt 開發者體驗的核心。為了持續改進它,我們需要一種簡單有效的方式,直接在每個頁面上收集使用者反饋。以下是我們如何設計和實現我們的反饋小元件,其靈感來源於 Plausible 的隱私優先方法。
為什麼需要反饋小元件?
目前,使用者可以透過建立 GitHub Issues 或直接聯絡我們來提供對我們文件的反饋。雖然這些渠道很有價值並且仍然很重要,但它們要求使用者離開當前上下文,並採取幾個步驟來分享他們的想法。
我們想要一些不同的東西
- 上下文相關:直接整合到每個文件頁面中
- 無摩擦:最多兩次點選即可提供反饋
- 尊重隱私:不進行個人跟蹤,設計上符合 GDPR
技術架構
我們的解決方案包含三個主要元件
1. 帶有 Motion 動畫的前端
介面結合了 Vue 3 的 Composition API 和Motion for Vue以建立引人入勝的使用者體驗。該小元件使用佈局動畫實現平滑的狀態過渡,並使用彈簧物理效果實現自然的反饋。useFeedback
可組合函式處理所有狀態管理,並在使用者在頁面之間導航時自動重置。
例如,這是成功狀態動畫
<template>
<!-- ... -->
<motion.div
v-if="isSubmitted"
key="success"
:initial="{ opacity: 0, scale: 0.95 }"
:animate="{ opacity: 1, scale: 1 }"
:transition="{ duration: 0.3 }"
class="flex items-center gap-3 py-2"
role="status"
aria-live="polite"
aria-label="Feedback submitted successfully"
>
<motion.div
:initial="{ scale: 0 }"
:animate="{ scale: 1 }"
:transition="{ delay: 0.1, type: 'spring', visualDuration: 0.4 }"
class="text-xl"
aria-hidden="true"
>
✨
</motion.div>
<motion.div
:initial="{ opacity: 0, x: 10 }"
:animate="{ opacity: 1, x: 0 }"
:transition="{ delay: 0.2, duration: 0.3 }"
>
<div class="text-sm font-medium text-highlighted">
Thank you for your feedback!
</div>
<div class="text-xs text-muted mt-1">
Your input helps us improve the documentation.
</div>
</motion.div>
</motion.div>
<!-- ... -->
</template>
您可以在此處找到反饋小元件的原始碼此處.
2. 受 Plausible 啟發的匿名化
挑戰在於在保留隱私的同時檢測重複項(使用者改變主意)。我們從Plausible的方法中汲取靈感,以無需 Cookie 即可計算獨立訪問者.
export async function generateHash(
today: string,
ip: string,
domain: string,
userAgent: string
): Promise<string> {
const data = `${today}+${domain}+${ip}+${userAgent}`
const buffer = await crypto.subtle.digest(
'SHA-1',
new TextEncoder().encode(data)
)
return [...new Uint8Array(buffer)]
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
此方法透過組合以下內容生成每日唯一識別符號
- IP + User-Agent:每個 HTTP 請求都會自然傳送
- 域名:啟用環境隔離
- 當前日期:強制識別符號每日輪換
為什麼這樣安全?
- IP 和 User-Agent 永遠不會儲存在資料庫中
- 雜湊每日更改,防止長期跟蹤
- 從雜湊中逆向工程原始資料非常困難
- 設計上符合 GDPR(無持久個人資料)
3. 具有衝突處理的資料庫持久化
首先,我們定義反饋表的 schema,並在 path
和 fingerprint
列上新增唯一約束。
export const feedback = sqliteTable('feedback', {
id: integer('id').primaryKey({ autoIncrement: true }),
rating: text('rating').notNull(),
feedback: text('feedback'),
path: text('path').notNull(),
title: text('title').notNull(),
stem: text('stem').notNull(),
country: text('country').notNull(),
fingerprint: text('fingerprint').notNull(),
createdAt: integer({ mode: 'timestamp' }).notNull(),
updatedAt: integer({ mode: 'timestamp' }).notNull()
}, table => [uniqueIndex('path_fingerprint_idx').on(table.path, table.fingerprint)])
然後,在伺服器端,我們使用Drizzle以及 UPSERT
策略
await drizzle.insert(tables.feedback).values({
rating: data.rating,
feedback: data.feedback || null,
path: data.path,
title: data.title,
stem: data.stem,
country: event.context.cf?.country || 'unknown',
fingerprint,
createdAt: new Date(),
updatedAt: new Date()
}).onConflictDoUpdate({
target: [tables.feedback.path, tables.feedback.fingerprint],
set: {
rating: data.rating,
feedback: data.feedback || null,
country,
updatedAt: new Date()
}
})
這種方法允許使用者在當天改變主意時進行更新,為新反饋建立記錄,並自動實現每頁和每個使用者的去重。
您可以在此處找到伺服器端原始碼此處.
用於一致性的共享型別
我們使用 Zod 進行執行時驗證和型別生成
export const FEEDBACK_RATINGS = [
'very-helpful',
'helpful',
'not-helpful',
'confusing'
] as const
export const feedbackSchema = z.object({
rating: z.enum(FEEDBACK_RATINGS),
feedback: z.string().optional(),
path: z.string(),
title: z.string(),
stem: z.string()
})
export type FeedbackInput = z.infer<typeof feedbackSchema>
這種方法確保了前端、API 和資料庫之間的一致性。
接下來
該小元件現已在所有文件頁面上線。我們的下一步是在 nuxt.com 中構建一個管理介面,以分析反饋模式並識別需要改進的頁面。這將幫助我們根據真實使用者反饋持續提高文件質量。
完整的原始碼可在GitHub供您參考和貢獻!