在 React 项目中优雅实现新用户引导:HagiCode 的 driver.js 实践
助开发者更高效地完成代码任务。你可以在 GitHub 上查看我们的开源代码。
为什么选择 driver.js
在技术选型阶段,我们评估了几个主流的引导库,怎么说呢,每个都有自己的特点:
- Intro.js:功能强大但体积较大,样式定制相对复杂
- Shepherd.js:API 设计很好,但对于我们的场景来说有点"重"
- driver.js:轻量、简洁、API 直观,且支持 React 生态
最终我们选择了 driver.js,其实也没什么特别的理由,主要基于以下几点考虑:
- 轻量级:核心库体积小,不会显著增加打包体积
- API 简洁:配置项清晰直观,上手快
- 灵活性:支持自定义定位、样式和交互行为
- 动态导入:可以按需加载,不影响首屏性能
选型这件事,其实没有最好的,只有最合适的罢了。
技术实现
核心配置
driver.js 的配置非常直观,以下是 HagiCode 项目中的核心配置:
import { driver } from 'driver.js'; |
|
import 'driver.js/dist/driver.css'; |
|
const newConversationDriver = driver({ |
|
allowClose: true, // 允许用户关闭引导 |
|
animate: true, // 启用动画效果 |
|
overlayClickBehavior: 'close', // 点击遮罩层关闭引导 |
|
disableActiveInteraction: false, // 保持元素可交互 |
|
showProgress: false, // 不显示进度条(我们有自定义进度管理) |
|
steps: guideSteps // 引导步骤数组 |
|
}); |
这些配置背后的考虑是:
allowClose: true- 尊重用户选择,不强制完成引导,毕竟强扭的瓜不甜disableActiveInteraction: false- 某些步骤需要用户实际操作(如输入文字),所以不能禁用交互overlayClickBehavior: 'close'- 给用户一个快速的退出方式
状态管理
引导状态的持久化是关键——我们不希望每次刷新页面都重新引导,那样挺烦人的。HagiCode 使用 localStorage 来管理引导状态:
export type GuideState = 'pending' | 'dismissed' | 'completed'; |
|
export interface UserGuideState { |
|
session: GuideState; |
|
detailGuides: Record<string, GuideState>; |
|
} |
|
// 读取状态 |
|
export const getUserGuideState = (): UserGuideState => { |
|
const state = localStorage.getItem('userGuideState'); |
|
return state ? JSON.parse(state) : { session: 'pending', detailGuides: {} }; |
|
}; |
|
// 更新状态 |
|
export const setUserGuideState = (state: UserGuideState) => { |
|
localStorage.setItem('userGuideState', JSON.stringify(state)); |
|
}; |
我们定义了三种状态:
pending:引导进行中,用户还未完成或跳过dismissed:用户主动关闭了引导completed:用户完成了所有步骤
对于提案详情页的引导,我们还支持更细粒度的状态追踪(通过 detailGuides 字典),因为一个提案可能会经历多个阶段(草稿、审核、执行完成),每个阶段都需要不同的引导。毕竟,事情的状态总是在变化的。
目标元素定位
driver.js 使用 CSS 选择器来定位目标元素。HagiCode 采用了一个约定:使用 data-guide 自定义属性来标记引导目标:
const steps = [ |
|
{ |
|
element: '[data-guide="launch"]', |
|
popover: { |
|
title: '开始新对话', |
|
description: '点击这里创建一个新的对话会话...' |
|
} |
|
} |
|
]; |
在组件中这样使用:
<button data-guide="launch" onClick={handleLaunch}> |
|
新建对话 |
|
</button> |
这种做法的好处是:
- 避免与业务样式类名冲突
- 语义清晰,一眼就能看出这个元素与引导相关
- 便于统一管理和维护
动态导入优化
因为引导功能只在特定场景下才需要(比如新用户第一次访问),我们采用动态导入来优化初始加载性能:
const initNewUserGuide = async () => { |
|
// 动态导入 driver.js |
|
const { driver } = await import('driver.js'); |
|
await import('driver.js/dist/driver.css'); |
|
// 初始化引导 |
|
const newConversationDriver = driver({ |
|
// ...配置 |
|
}); |
|
newConversationDriver.drive(); |
|
}; |
这样 driver.js 及其样式文件只会在需要时才加载,不会影响首屏性能。毕竟,谁愿意为暂时用不到的东西付出等待的代价呢?
引导流程设计
HagiCode 实现了两条引导路径,覆盖了用户的核心使用场景。
会话引导(10步)
这条引导帮助用户完成从创建对话到提交第一个完整提案的整个流程:
- launch - 启动引导,介绍"新建对话"按钮
- compose - 引导用户在输入框中输入请求
- send - 引导点击发送按钮
- proposal-launch-readme - 引导创建 README 提案
- proposal-compose-readme - 引导编辑 README 请求内容
- proposal-submit-readme - 引导提交 README 提案
- proposal-launch-agents - 引导创建 AGENTS.md 提案
- proposal-compose-agents - 引导编辑 AGENTS.md 请求
- proposal-submit-agents - 引导提交 AGENTS.md 提案
- proposal-wait - 说明 AI 正在处理,请稍候
这条引导的设计思路是:通过两个实际的提案创建任务(README 和 AGENTS.md),让用户亲手体验 HagiCode 的核心工作流。毕竟,纸上得来终觉浅,绝知此事要躬行。
下面这几张图,对应的就是会话引导里的几个关键节点:

会话引导的第一步,先把用户带到“新建普通会话”的入口上。

接着引导用户在输入框里写下第一句请求,降低第一次开口的门槛。

输入完成后,再明确提示用户发送第一条消息,让操作路径更连贯。

当两个提案都创建完成后,引导会回到会话列表,让用户知道接下来只需要等待系统继续执行和刷新。
提案详情引导(3步)
当用户进入提案详情页时,根据提案的当前状态触发对应的引导:
- drafting(草稿阶段)- 引导用户查看 AI 生成的计划
- reviewing(审核阶段)- 引导用户执行计划
- executionCompleted(完成阶段)- 引导用户归档计划
这条引导的特点是状态驱动——根据提案的实际状态动态决定显示哪个引导步骤。事物总是在变化,引导也应该跟着变化才是。
下面这张图展示的是提案详情页在“起草阶段”的引导状态:

在这个阶段,引导会把用户注意力聚焦到“生成规划”这个关键动作上,避免第一次进入详情页时不知道该先做什么。
元素渲染重试机制
在 React 应用中,引导目标元素可能还没渲染完成(比如等待异步数据加载)。为了处理这种情况,HagiCode 实现了一个重试机制:
const waitForElement = (selector: string, maxRetries = 10, interval = 100) => { |
|
let retries = 0; |
|
return new Promise<HTMLElement>((resolve, reject) => { |
|
const checkElement = () => { |
|
const element = document.querySelector(selector) as HTMLElement; |
|
if (element) { |
|
resolve(element); |
|
} else if (retries < maxRetries) { |
|
retries++; |
|
setTimeout(checkElement, interval); |
|
} else { |
|
reject(new Error(`Element not found: ${selector}`)); |
|
} |
|
}; |
|
checkElement(); |
|
}); |
|
}; |
在初始化引导前调用这个函数,确保目标元素已经存在。有时候,多等待一下也是值得的。
最佳实践总结
基于 HagiCode 的实践经验,这里分享几个关键的最佳实践:
1. 引导应该是"可逃离的"
不要强制用户完成引导。有些用户是探索型的,他们更喜欢自己摸索。提供清晰的"跳过"按钮,并记住用户的选择,下次不再打扰。毕竟,美的事物或人,不一定要占有,只要她还是美的,自己好好看着她的美就好了。
2. 引导内容要简洁有力
每个引导步骤应该聚焦于单一目标:
- Title:简短清晰,不超过 10 个字
- Description:直击要点,告诉用户"这是啥"和"为啥要用"
避免长篇大论的说明——用户在引导阶段的注意力是很有限的。话说多了,反而没人愿意看。
3. 选择器要稳定
使用稳定的、不频繁变化的元素标记方式。data-guide 自定义属性是一个好选择,避免依赖 class 名或 DOM 结构,因为这些很容易在重构中变化。代码总是在变化的,但有些东西应该尽量保持稳定。
4. 测试你的引导
HagiCode 为引导功能编写了完整的测试用例:
describe('NewUserConversationGuide', () => { |
|
it('应该正确初始化引导状态', () => { |
|
const state = getUserGuideState(); |
|
expect(state.session).toBe('pending'); |
|
}); |
|
it('应该正确更新引导状态', () => { |
|
setUserGuideState({ session: 'completed', detailGuides: {} }); |
|
const state = getUserGuideState(); |
|
expect(state.session).toBe('completed'); |
|
}); |
|
}); |
测试可以确保在重构代码时不会不小心破坏引导功能。毕竟,谁也不希望改点代码就把之前的功能搞坏了。
5. 性能优化
- 使用动态导入延迟加载引导库
- 避免在用户已经完成引导后仍然初始化引导逻辑
- 考虑引导动画的性能影响,低端设备上可以关闭动画
性能这东西,就像生活一样,该省的地方还是要省的。
总结
更多推荐
所有评论(0)