wp-calypso Guided Tours 架构解析:基于 actionLog 的向导状态派生机制
前端CMS【免费下载链接】wp-calypsoThe JavaScript and API powered WordPress.com项目地址https://gitcode.com/gh_mirrors/wp/wp-calypso点击查看免费下载Guided Tours 是 wp-calypsoWordPress.com 的 JavaScript/API 前端内置的一套用户引导onboarding tour子系统用于根据用户行为在合适的时机自动弹出分步操作指引。本文以 client/layout/guided-tours/docs/ARCHITECTURE.md 为核心骨架结合源码剖析其数据驱动、状态尽可能少、逻辑全部收敛于选择器的架构设计读完你既能掌握其数据流与决策算法也能理解如何扩展 trigger、编写自定义 tour。一、设计前提为何要引入 actionLogGuided Tours 从设计之初就锚定了两个前提它必须足够灵活能容纳各式各样的 tour并基于不同 trigger 来启动它们团队当时还无法预知这些 trigger 最终会长什么样。配合可测试性testability、可调试性debuggability以及最小化状态问题等要求最终催生了actionLog这一核心数据结构。从实现看actionLog就是一份带时间戳的 Redux action 列表只收录那些被派发且类型命中相关类型清单的 action。这里的相关定义在 client/state/ui/action-log/reducer.jsconst relevantTypes { // 捕获某一类型的所有 action // ACTION_TYPE, // 捕获符合某判据的类型 // ACTION_TYPE: ( action ) isValid( action.data ) GUIDED_TOUR_UPDATE, THEMES_REQUEST_SUCCESS, ROUTE_SET, SITE_SETTINGS_RECEIVE, };几点值得注意的设计细节不持久化state.ui.actionLog刻意不做持久化每次新的 Calypso 会话都从空列表开始reducer 默认状态为[]。长度封顶只保留最近 50 条更旧的 action 被挤出见 reducer.js 的[ ...state, action ].slice( -50 )。类型匹配可扩展relevantTypes的值既可以只是 action 类型名全量捕获也可以是谓词函数用于只捕获符合特定判据的 action。Analytics 事件也算数除了类型匹配带有特定 analytics 记录的 action 也会被收录relevantAnalyticsEvents目前包括calypso_themeshowcase_theme_click见 reducer.js。二、派生状态用选择器从日志中算出一切有了actionLog就可以编写任意数量的选择器selector去处理日志、派生有用信息。例如收集ROUTE_SETaction就能得到用户近期的导航历史收集FETCH_FOOS_SUCCESS就能让某个 tour 只在对应数据从服务端取回后才切换到下一步更复杂的行为模式也可以被推断——比如用户连续快速进出多个 Calypso 分区、中间夹杂其他 action或许可以推断他正在寻找某个功能但没找到此时就可以上下文相关地提供帮助。这些利用 action log 的种种方式共同构成了triggers。目前仓库中只有一族 trigger基于导航到特定路径的 trigger这正是 tour 作者在 tour 中写Tour path/themes时所触发的机制。Guided Tours 的设计哲学是几乎不维护显式状态例如当前在 tour 的第几步、这个 tour 花了多久完成而是尽可能依赖 actionLog——其中专门定义了GUIDED_TOUR_UPDATEaction 来标记 tour 的步骤切换。最终效果是没有state.guidedTours.isTourRunning Tour /这种写法取而代之的是一串级联的选择器它们最终计算出 Guided Tours 的当前状态。这一整套级联之所以可行依赖createSelector的大量记忆化memoizationgetGuidedTourState └── findEligibleTour ├── findOngoingTour └── findTriggeredTour └── getToursFromFeaturesReached在 client/state/guided-tours/selectors/index.js 中findEligibleTour给出了 GT 决策算法的全貌export const findEligibleTour createSelector( ( state ) { if ( shouldBailAllTours( state ) ) { return; } return ( findOngoingTour( state ) || ( ! shouldBailNewTours( state ) ( findRequestedTour( state ) || findTriggeredTour( state ) ) ) || undefined ); }, // 虽然 findEligibleTour 函数体内还用到其他状态选择器 // 但我们刻意把依赖列表收敛为以下两项 [ getActionLog, getToursHistory ] );逐条拆解这段决策逻辑从最后的依赖数组看主要信息来源是actionLog和 tours history一个存储在state.preferences中的用户偏好除了若干 bail 机制之外决策过程优先选中进行中的 tourfindOngoingTour其次是显式请求的 tourfindRequestedTour例如通过 URL 查询参数?tourtourName最后才回落到可被触发的 tourfindTriggeredTour依据 actionLog 中跟踪到的行为如果三者都落空undefined表示没有 tour 需要被选中。findOngoingTour与findRequestedTour相对简单前者查找是否已经在 tour 中后者解析getTourFromQuery结合getInitialQueryArguments与getCurrentQueryArguments并利用hasJustSeenTour避免同一会话内刚看过又重复弹出见 index.js。findTriggeredTour才是真正可扩展的选择器const findTriggeredTour ( state ) { if ( ! preferencesLastFetchedTimestamp( state ) ) { debug( No fresh user preferences, bailing. ); return; } const toursFromTriggers getToursFromFeaturesReached( state ); const toursToDismiss getToursSeen( state ); const newTours toursFromTriggers.filter( ( tour ) ! toursToDismiss.includes( tour ) ); return newTours.find( ( tour ) { const { when () true } guidedToursConfig.find( ( { name } ) name tour ); return when( state ); } ); };它依次做了三件事匹配 trigger当前即getToursFromFeaturesReached——通过把 actionLog 中的ROUTE_SET记录按时间倒序排列再用tourMatchesPath支持单路径或路径数组前缀匹配见 index.js与guidedToursConfig中的 tour 定义比对得到用户最近访问过的路径所对应的 tour 名列表。剔除应被 dismiss 的 tour当前即getToursSeen——把 tours history 中所有已看过的 tour 名去重。返回第一个有效 tourtour 有效当且仅当它没有特殊的when属性或者when( state )求值为true。when为 tour 提供了动态的起始条件机制它是一个预期返回布尔值的选择器。另外index.js 中的hasTourJustBeenVisible以最近一次GUIDED_TOUR_UPDATE且shouldShow false距今是否小于 1 分钟来判断新 tour 是否刚展示过从而抑制连番弹窗SECTIONS_WITHOUT_TOURSsignup、upgrades/checkout、checkout-pending、checkout-thank-you见 index.js则声明了禁止出 tour 的分区。三、视图层从 GuidedTours 到 makeTour 的组件化3.1 最外层GuidedTours 组件在最外层Guided Tours 是一个单一组件GuidedTours渲染在 Calypso 的Layout中。它本质上是一个包装器职责有三通过connect与QueryPreferences满足子系统的数据需求绑定 Redux action creators方便后续消费在RootChild中渲染AllTours——因为 tour 的步骤 DOM 节点不能绑定在Layout或任何特定子树下。实现见 client/layout/guided-tours/component.jsx组件通过getGuidedTourState拿到tourState含tour、stepName、shouldShow通过getLastAction拿到最近的 actionshouldComponentUpdate只在tourState引用变化时重渲染start/next/quit三个回调分别派发nextGuidedTourStep/quitGuidedTour并同步记录calypso_guided_tours_start、calypso_guided_tours_seen_step、calypso_guided_tours_finished|quit等 Tracks 事件。值得注意的是shouldShow还会结合getSectionGroup判断若处于 Gutenberg 编辑器分区getSectionGroup( state ) gutenberg则不出 tour见 index.js。3.2 AllTours 与 combineToursAllTours由combineTours创建从 client/layout/guided-tours/config-elements/combine-tours.jsx 导入。它内部像一个 switch只渲染上层传入的tourNameprop 所对应的那个 tour。3.3 makeTour无状态、生命周期感知的 tour 组件严格来说一个 tour 是无状态、基于类、生命周期感知的组件但 tour 作者并不会显式地写 React 组件——而是把一棵元素树纯 JSX交给辅助函数makeTour。这样设计的初衷是构建 tour 的接口应当简单不应把收集并传递 props的负担压在 tour 作者身上。于是这里必须引入一些魔法让 Guided Tours 感知两类数据静态的 per-tour 数据包含哪些步骤、如何定位等——这正是 tour 作者用纯 JSX 写下的内容动态的 Guided Tours 状态是否在 tour 中、当前在哪一步等——从GuidedTours一路传递下来。最初的方案基于React.cloneElement但每次渲染都要 clone带来了性能问题以及由生命周期被破坏引发的诡异 bug。最终解法是利用 React 的contextmakeTour助手创建一个组件它直接渲染那份纯 JSX 树而不做任何改动但首先把所需的动态数据状态与绑定的 actions通过TourContext.Provider放进 context。核心实现见 client/layout/guided-tours/config-elements/make-tour.js它在getDerivedStateFromProps中基于 props 计算出tourContext包含next、quit、start、isValid、lastAction、step、branching、isLastStep、tour、tourVersion、dispatch等渲染时用createElement( TourContext.Provider, { value }, tree )包住原始 JSX 树。3.4 配置元素Tour / Step / Next / Continue / Quit最后纯 JSX 的 tour 描述由一组 config elements见 client/layout/guided-tours/config-elements 目录含 tour.js、step.tsx、next.jsx、continue.jsx、quit.jsx 等搭建成 tour 的步骤流Tour、Step、Next、Continue、Quit。这些组件都是 context-aware 的内含专门逻辑最终建立起 tour 的控制流步进、跳过、退出。tour 的触发条件配置含path、when、version等则汇总在 client/layout/guided-tours/config.js例如Tour path/themes即在此声明仓库中可参考的完整示例有 docs/examples/tours/simple-payments-end-of-year-guide.jsx 与 tours/checklist-site-title-tour/index.jsx 等真实 tour。四、步骤定位positioning 库与 RootChild 渲染Step的定位通过placement属性配置与target、arrow协同工作。底层使用 Guided Tours 的positioning库最终算出一对(x, y)坐标再转换成left/right, top这组 CSS 属性挂到 tour 步骤元素上。为了让步骤渲染不被 Calypso 的 CSS例如overflow: hidden;约束或妨碍步骤是RootChild的子节点——也就是说它们并不挂载在它们所指向的 UI 元素即步骤的 target之下而是贴近文档根部。这带来若干重要推论定位必须被精确计算以模拟步骤紧贴 target的效果偶尔需要修正 z-index 差异滚动场景要求定位逻辑知道我们的 target 更接近哪个滚动容器更重要的是定位是一个相对静态的过程需要在适当时机被主动刷新——滚动与窗口 resize 是 GT 自动防御的两类场景但只要布局变化理论上都可能出现错位。这在导航时通常不是问题但在渲染一个步骤到某视图上而该视图上有张正在加载、加载完成后会撑大布局的图片这类场景中就可能变得有风险。五、从测试看行为契约仓库为这套机制提供了可验证的行为契约client/state/guided-tours/test/selectors.js 覆盖了选择器层findEligibleTour的优先级进行中 请求中 触发、getToursFromFeaturesReached的路径匹配、hasTourJustBeenVisible的一分钟阈值等client/layout/guided-tours/test/tour-branching.js 与 test/utils.js 验证了tourBranching分支控制流等 config-elements 层面的逻辑。六、小结与扩展路径Guided Tours 的架构可以用一句话概括用一条不持久化、上限 50 条、按类型筛选的 actionLog 作为唯一事实来源再用一串createSelector记忆化的选择器级联派生出该不该出 tour、出哪个 tour、出到第几步视图层则通过GuidedTours → AllTours(combineTours) → makeTour(React context)的链路把动态状态以 context 形式注入纯 JSX 的 tour 描述最终由positioning库 RootChild完成精确定位渲染。如果你要扩展新的 trigger 类型只需沿着两条线索走一是扩充actionLog/reducer.js中relevantTypes的收录范围甚至可以加谓词二是仿照getToursFromFeaturesReached在 client/state/guided-tours/selectors/index.js 中新增派生选择器并把它并入findTriggeredTour的候选集合文档注释里预留了getToursFromPurchases、getToursFromFirstActions等例子。同一份架构文档还配套了 API.md、TUTORIAL.md、DEBUGGING.md 与 ARCHITECTURE-FUTURE.md分别对应配置元素 API、从零编写 tour 的教程、调试手段与未来演进方向可作为继续深入的路标。赞分享前端CMS【免费下载链接】wp-calypsoThe JavaScript and API powered WordPress.com项目地址https://gitcode.com/gh_mirrors/wp/wp-calypso点击查看免费下载相关推荐Guided Tours 未来架构演进指南Calypso 引导框架的懒加载、状态感知与 actionLog 规模化Guided Tours 未来架构演进指南Calypso 引导框架的懒加载、状态感知与 actionLog 规模化 导读 本文基于 wp calypso 仓库前端CMS在 wp-calypso 中构建 Guided Tours 新手引导教程从零编写一个 View Site 引导 Tour在 wp calypso 中构建 Guided Tours 新手引导教程从零编写一个 View Site 引导 Tour 本教程是 wp calypso前端CMSwp-calypso Guided Tours 框架完全指南从入门到源码级实战wp calypso Guided Tours 框架完全指南从入门到源码级实战 本文是一份围绕 WordPress.com 开源前端项目 wp calypso前端CMS创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考