在 Unleash Admin UI 中构建产品内 UX 调研组件:UX Tweak Widgets 的架构、Flag 契约与实现剖析
2026/9/15 21:26:18 网站建设 项目流程

在 Unleash Admin UI 中构建产品内 UX 调研组件:UX Tweak Widgets 的架构、Flag 契约与实现剖析

【免费下载链接】unleashOpen-source feature management platform项目地址: https://gitcode.com/GitHub_Trending/un/unleash

Unleash 前端在frontend/src/component/uxtweak/目录中实现了一套产品内(in-app)UX 调研组件,用于在管理界面中展示由 UX Tweak 平台创建的问卷。本文以该目录的 ARCHITECTURE.md 为骨架,结合源码深入讲解"flag 即投放通道"的契约设计、懒加载与错误隔离的组件流、问卷生命周期控制(展示一次、7 天宽限期、3 次展示上限)以及 fire-and-forget 的提交通道,帮助你理解如何在 Unleash 管理后台中安全、可控地接入第三方产品内调研。

概览:为什么"Flag 就是投放通道"

UX Tweak Widgets 是嵌入 Unleash Admin UI 的产品内 UX 调研组件(目前仅支持问卷 survey)。其运行模型与传统的"前端打包问卷内容"完全不同:

  • 研究人员在UX Tweak平台编写问卷;
  • UX Tweak 将问卷发布为一个Unleash feature flag
  • 本目录通过 Admin UI 自己的前端 SDK client@unleash/proxy-client-react)发现这个 flag,并渲染对应 widget;
  • Admin UI自身不携带任何问卷内容——flag 的 variant payload 就是完整的问卷数据,"flag 即投放通道"。

也就是说,问卷的创建、编辑、定向、灰度全在 Unleash 的 flag 机制上完成,前端只负责"发现并渲染"。

Flag 契约:一个问卷 = 一个 feature flag

命名约定

一个问卷活动对应一个 feature flag,命名必须遵循:

uxtweak-survey-<page-slug>-<id>

只有uxtweak-survey-这个前缀是契约性的,前缀之后的所有内容(page-slug、id)对消费者来说都是**不透明(opaque)**的,不应被前端解析或依赖。源码中前缀常量定义在 surveys.ts:

export const SURVEY_FLAG_PREFIX = 'uxtweak-survey-';

Variant 载荷:完整的问卷 JSON

flag 携带一个名为config的 variant,其 JSON payload 就是整个问卷。契约示例如下:

{ "v": 1, "surveyId": "sv_…", "page": "/projects", // 或 "*" 表示所有页面 "title": "Quick feedback", "intro": "…", "questions": [ { "id": "q1", "type": "rating", "prompt": "…", "required": true }, { "id": "q2", "type": "single", "options": ["a","b"], "prompt": "…", "required": false }, { "id": "q3", "type": "text", "prompt": "…", "required": false } ], "submitBase": "https://…" // 问卷响应将被 POST 到的 UX Tweak 服务端 }

各字段语义(对应 surveys.ts 的解析实现):

字段类型说明
vnumberpayload 版本号,当前必须为1,否则整体拒绝
surveyIdstring问卷全局唯一 id,用于去重、宽限期与印象计数
pagestring页面匹配规则:具体路径(如/projects)或*(所有页面)
titlestring卡片标题
introstring可选引导文案,非字符串时回退为空串
questionsarray1~10 个问题对象(MAX_QUESTIONS = 10
submitBasestringUX Tweak 服务端地址,响应会 POST 到${submitBase}/public/survey/responses

问题对象支持三种类型,定义于SurveyQuestionConfig(surveys.ts):

  • rating:星级评分(id+prompt+required),前端渲染为 MUIRating,最大 5 星;
  • single:单选,必须提供非空字符串数组options,前端渲染为 radio group;
  • text:自由文本,前端渲染为多行TextField

定向与决策边界

定向逻辑不在这份代码里。投放百分比(rollout percentage)、约束条件(constraints)都定义在 flag 的 strategy 上,由 Unleash 在客户端看到 flag之前完成评估。本目录从不做任何定向决策,它只回答一个问题:

payload 里的page是否匹配用户当前正在看的页面?

匹配规则见pageMatches(surveys.ts):

export const pageMatches = (page: string, pathname: string): boolean => page === '*' || normalizePath(page) === normalizePath(pathname);

匹配是精确匹配,但对结尾斜杠宽容(normalizePath),且*匹配一切。

版本化与全有或全无解析

payload 是版本化的:v不是1时整份问卷被拒绝,这样未来出现新形状的 payload 也绝不可能在旧消费者上"半渲染"。

解析是全有或全无(all-or-nothing)的,理由与版本化一致:一份"只能渲染一半"的问卷比没有问卷更糟。因此只要有一个问题格式错误,整个问卷就被拒绝。parseSurveyPayload对任何畸形输入都返回null绝不抛出异常

  • payload 类型不是 JSON;
  • JSON.parse失败(非法 JSON);
  • 解析结果不是对象(注意:"null"会被解析成null,同样被拒);
  • 缺少surveyId/page/title/submitBase等必填字段(isNonEmptyString校验);
  • questions不是数组、为空或超过 10 个;
  • 任一问题缺id/promptsingle类型没有非空字符串数组optionstype不在三种类型之内。

这样设计是因为扫描发生在 SDK 事件回调里、位于任何 React Error Boundary 之外,抛异常会直接冒泡到外层应用。

组件流:门卫 → 懒加载 Runner → 问卷卡片

整体组件层级如下(App登录态分支):

App (logged-in branch) └─ UxTweakWidgets gate —— 主 bundle 中的唯一成员 └─ (lazy, error-isolated) UxTweakRunner 懒加载的 widget chunk;每种 widget 对应一个宿主 └─ useActiveSurvey() → UxSurveyCard

UxTweakWidgets:门卫(gate)

UxTweakWidgets(UxTweakWidgets.tsx)是整个功能在主 bundle 中的唯一常驻代码。它挂在 App.tsx 中,紧邻FeedbackNPS,同时受两个条件门控:

{isLoggedIn && uxTweakSurveysEnabled ? ( <UxTweakWidgets />
  • isLoggedIn:绝不在登录页渲染;
  • uxTweakSurveys:内部 uiConfig flag(UNLEASH_EXPERIMENTAL_UX_TWEAK_SURVEYS,企业版 uiConfig 标志,见 uiConfig.ts),同时充当总开关(kill switch)

门卫通过useFlags()监听 SDK client 是否有任何以uxtweak-开头的 flag,只有发现时才lazy(() => import('./UxTweakRunner.tsx'))加载 widget chunk。没有任何活动(campaign)时,UxTweakWidgets只产生一个事件订阅,其余什么都不做——成本几乎为零。

门卫使用useLatched(useLatched.ts)闩锁:一旦某个uxtweak-flag 出现过,就保持挂载。原因在于,flag 刷新导致最后一个 flag 消失(如 rollout 重新分桶、活动暂停)时,不能卸载 Runner 从而销毁用户正在作答的问卷。flag 消失之后,已加载 chunk 渲染null是全部代价。

子树自带静默 ErrorBoundaryfallbackRender={() => null}):没有它,widget 崩溃会冒泡到ApplicationRoot中的应用级边界,用错误布局替换整个 Admin UI。产品内调研绝不允许把产品搞挂。

UxTweakRunner:懒加载 chunk 与问卷宿主

UxTweakRunner(UxTweakRunner.tsx)是lazy()要求的默认导出模块。目前它同时兼任问卷宿主:

  • 通过useLatched(useActiveSurvey())闩锁第一个useActiveSurvey产出的问卷;
  • 渲染该卡片直到访客完成作答(提交或关闭);
  • 卡片以key={survey.surveyId}标识,跨 session 切换活动时不会残留组件状态。

一旦显示,卡片能扛过 flag 刷新、payload 编辑和路由变化——正在作答的访客绝不能被突然抽走卡片。由于闩锁保持了 config 的对象身份,活动中的 payload 在线编辑不会重挂载 keyed 卡片,也就不会清空用户已输入的答案

闩锁刻意永不清理:作答完成后,卡片自身的状态机渲染null,宽限期又会压制其他问卷——因此一次会话最多展示一份问卷是结构上保证的(by construction)。

当未来出现更多 widget 类型(如 chat、interviews)时,每种类型会在这里拥有自己的宿主;问卷专属的扫描逻辑届时再基于真实消费者做泛化,而不是提前抽象。

useActiveSurvey:当前页面上的问卷

useActiveSurvey(useActiveSurvey.ts)是一个纯派生Hook:

export const useActiveSurvey = (): SurveyConfig | null => { const flags = useFlags(); const { pathname } = useLocation(); if (isInSurveyGracePeriod()) { return null; } return ( scanSurveys(flags, pathname).find( (survey) => !hasSeenSurvey(survey.surveyId) && !hasReachedImpressionCap(survey.surveyId), ) ?? null ); };
  • flag 变化时由 SDK 的useFlags()触发重渲染,路由变化时由useLocation()触发——没有任何自定义订阅代码
  • 多个问卷匹配同一页面时,flag 名最小者胜出——scanSurveyssort,因为 SDK 不保证多次刷新间 flag 的顺序,赢家绝不能因页面加载而改变。

survey/surveys.ts:契约模块

这是整个功能的"契约中心"(surveys.ts),集中了:

  • 前缀常量SURVEY_FLAG_PREFIX
  • payload 类型定义(SurveyConfigSurveyQuestionConfigSurveyAnswers);
  • 全有或全无解析器parseSurveyPayload
  • 扫描管线scanSurveysflags → 前缀过滤 → 解析 → 页面匹配 → 排序
export const scanSurveys = (flags: IToggle[], pathname: string): SurveyConfig[] => flags .filter((flag) => flag.name.startsWith(SURVEY_FLAG_PREFIX)) .map((flag) => parseSurveyPayload(flag.name, flag.variant?.payload)) .filter((survey) => survey !== null) .filter((survey) => pageMatches(survey.page, pathname)) .sort((a, b) => a.flagName.localeCompare(b.flagName));

UxSurveyCard:浮动的右下角问卷卡片

UxSurveyCard(UxSurveyCard.tsx)是一个固定定位在右下角的浮动卡片(position: fixed; bottom/right: theme.spacing(3),宽度 360px),包含:标题、intro、以表单呈现的问题、提交按钮。每个问题类型一个小组件:

  • rating→ MUIRating(与FeedbackComponent一致,最大 5 星);
  • single→ radio group;
  • text→ 多行TextField

所有问题都是受控输入,共享一个以 question id 为 key 的answers记录。关键设计:

  • 所有答案统一存为字符串(评分也存字符串),因此"已作答"只有一条规则——trim 后非空,必答校验就是一次every()
    const canSubmit = survey.questions.every( (question) => !question.required || Boolean(answers[question.id]?.trim()), );
  • 提交按钮在必答题未全部作答前禁用;
  • 点击提交后卡片切换到本地thanks 状态——居中的确认视图,3 秒后自动淡出(THANKS_VISIBLE_MS = 3000)。淡出调度是可注入的scheduleLeaveprop,测试直接触发离开而不伪造 timer;
  • 任何状态下都可以关闭

卡片状态机为'answering' | 'thanks' | 'leaving' | 'closed'Fade动画的onExited将状态置为closed完成卸载。

提交:fire-and-forget 的 POST

submitSurveyResponse(submitSurveyResponse.ts)将清理后的答案(rating 转数字、空值剔除)交给 Runner,然后:

POST ${submitBase}/public/survey/responses { surveyId, visitorId, page, answers }
  • UX Tweak 服务端按(survey, visitor)upsert,因此没有 token
  • visitorId优先取 Unleash client 的sessionId(即 rollout 粘性哈希所用的值),缺失时铸造一个持久化的 UUID(uxtweak-visitor-id:v1crypto.randomUUID());
  • POST 使用keepalive: true并在 Runner 中.catch(() => {})吞掉失败——访客已经看到 thanks 视图,产品内调研绝不能因为一次失败请求拖垮产品;
  • 卡片保持纯展示(presentational),I/O 归 Runner 所有

展示频率控制:不打扰访客的三道闸

"一次会话最多一份问卷"还不足以避免打扰,模块在 localStorage 层实现了三道闸,全部位于 seenSurveys.ts,统一走仓库的createLocalStorage(createLocalStorage.ts,自动命名空间化、私密模式安全)。

1. 每个浏览器最多展示一次

提交或关闭会把问卷 id 记入单个 localStorage 条目uxtweak-surveys-seen:v1(字符串数组,只保留最新 50 个id)。useActiveSurvey每次扫描都用全新读取过滤已见 id,因此已完结的问卷在路由变化、页面加载后都不会再现,且无需任何响应式接线。因为每个 campaign 的surveyId全局唯一,重新发布为新 campaign 会自然再次展示。

2. 完结任何问卷触发 7 天全局宽限期

markSurveySeen同时写入标记uxtweak-survey-grace:v1,使用createLocalStorage自带的timeToLiveSEVEN_DAY_GRACE_PERIOD_MS = 7 * 24 * 60 * 60 * 1000)。useActiveSurvey在标记存在期间返回null(存储层在读取时自动删除过期标记,无需手写时钟数学)。这正是让同时命中多个活动的访客不会在完成一份后立刻收到下一份的机制。宽限期逻辑内聚在markSurveySeen内,意味着提交、关闭乃至未来的提交分支都会自动继承它。

3. 被忽略的问卷在 3 次展示后停止出现

Runner 每次页面加载为每份问卷记录一次印象(uxtweak-survey-impressions:v1;模块级Set让 remount 和 StrictMode 双重 effect 免费去重),useActiveSurvey跳过已展示MAX_IMPRESSIONS(= 3)次的问卷。被忽略的卡片不能永远纠缠用户,但一眼瞥过也不该直接烧掉配额。与所有频率存储一样,畸形条目 fail open:问卷照常展示,绝不会崩溃。

值得了解的架构决策

刻意使用 SDK 的useFlags()

它包装了getAllToggles(),返回的只是已为本访客评估为启用的 flag(因此无需再检查enabled),并且与isEnabled/getVariant不同,不产生 impression 事件——发现逻辑绝不能污染分析数据。同时它拥有 update-event 订阅,本目录因此没有任何订阅代码。

useLocation()匹配页面,不做轮询

BrowserRouter挂载时带basename,因此 pathname 已排除应用的基础路径,可以直接与 payload 的page比较。匹配精确、对结尾斜杠宽容,*匹配一切。

实际只有云版本生效

SDK client 只有在服务端注入的unleashTokenmeta 标签存在时(即 Unleash Cloud)才会启动。自托管(self-hosted)安装时客户端处于惰性状态,本目录渲染null

全程使用 MUI 主题令牌

卡片遵循 Admin UI 主题(含暗色模式),与其它浮动组件(如FeedbackNPS)保持一致。

路线图与现状

模块的演进路线(ARCHITECTURE.md):

  1. ✅ 发现 + 最小卡片(标题/intro、仅会话内关闭)
  2. ✅ 问题渲染(评分 / 单选 / 自由文本)、必答校验、自动消失的 thanks 状态、最多展示一次抑制
  3. ✅ 问卷间 7 天宽限期
  4. ✅ 作答中闩锁(卡片在 flag 刷新、payload 编辑、路由变化后仍存活直到完结)
  5. ✅ 印象上限(被忽略的问卷 3 次展示后不再出现)
  6. ✅ 确定性问卷顺序(每次页面加载 flag 名最小者胜出)
  7. ⏳ 进一步加固:跨标签页同步(cross-tab sync)
  8. ✅ 提交到submitBase(fire-and-forget POST,visitor id 取自 Unleash sessionId)

小结

UX Tweak Widgets 是"Unleash 能力自举"的一个典型范例:用 Unleash 自己的 flag、variant 和前端 SDK 来承载并定向产品内调研,同时在前端用严格的契约解析、懒加载 + 错误隔离、闩锁语义和多层频率控制,把第三方内容的风险压缩到最小。其设计原则——"flag 即投放通道、前端不做定向决策、畸形输入永远返回 null 而非抛出、调研绝不能拖垮产品"——对任何要在管理界面中集成第三方动态内容的场景都极具参考价值。

若要在本地继续深入,可从以下路径入手:

  • 契约与解析:surveys.ts、surveys.test.ts
  • 门卫与宿主:UxTweakWidgets.tsx、UxTweakRunner.tsx、UxTweakWidgets.test.tsx
  • 频率控制:seenSurveys.ts、seenSurveys.test.ts
  • 提交通道:submitSurveyResponse.ts、submitSurveyResponse.test.ts
  • 卡片 UI:UxSurveyCard.tsx、UxSurveyCard.test.tsx
  • 挂载位置与总开关:App.tsx、App.test.tsx、uiConfig.ts

【免费下载链接】unleashOpen-source feature management platform项目地址: https://gitcode.com/GitHub_Trending/un/unleash

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询