前端新手引导功能怎么做:库只是其中一步

接一个"给后台加新手引导"的需求时,最该先算清的不是选哪个库,而是这件事到底要担多少隐性成本。表面看只是加几个气泡提示,一个 npm 包就能搞定;实际要处理遮罩、高亮、定位、步骤状态、完成记录,还要扛住页面权限差异和异步渲染。库能解决遮罩和定位这一层,剩下决定成败的每一处,都得你自己写、自己兜。把这些成本估进去,你才会明白:选库只是第一步,而且是最便宜的一步。

我做那次后台改版引导时,产品一开始想把 9 个新功能都串起来。测试到用户走到第 4 步就开始疯狂点跳过,原因很直接:前几步讲的功能,跟他当天要完成的任务没关系。后来改成"只引导创建第一条配置",步骤从 9 缩到 3,完成率反而上来了。**新手引导不是说明书,它该帮用户完成一个动作。**这个认知一旦立住,前面那些工程成本才知道该往哪儿花。

先想清楚:引导到底解决什么问题

引导的目标不是把所有功能讲一遍,而是帮用户完成第一次关键操作。常见的合理目标就那么几种:创建第一条数据、找到核心入口、理解新版页面变化、完成一次配置流程、发现藏得较深的重要功能。内容一多,用户直接跳过。一个 12 步的引导看着信息完整,实际很可能让人在真正开始用产品前就耗光耐心。更好的判断标准是:这一步能不能帮用户完成一个具体动作。只是介绍界面上有个按钮、可用户暂时用不到,就不该放进引导。想清楚这一层,能砍掉一大半后续要写的步骤代码,这是最划算的省成本方式。

引导形态:不是每个都需要遮罩

前端里常见的引导形态大致就是几种:高亮某个元素并显示提示、分步骤的气泡引导、页面首次访问弹窗、空状态里的操作提示、功能点旁边的小徽标、任务清单式引导。不是所有引导都得上遮罩和步骤条。有时一个清楚的空状态按钮,比复杂的分步引导更管用。比如用户还没创建项目时,空状态里直接放一个"创建项目"按钮,比进页面后用遮罩圈住右上角自然得多。**引导该融进任务,而不是打断任务。**所以我现在优先考虑"就地引导"——空状态、按钮旁提示、任务清单;只有入口确实难找、或者页面变化很大时,才动用遮罩式分步引导。遮罩很强势,用多了会让用户练出跳过反射。选轻不选重,也是在压实现成本。

选库时看什么:demo 好看不算数

到了真选库这一步,别被 demo 的漂亮骗了。真实项目里会撞上折叠菜单、弹窗、权限控制、动态路由、滚动容器和异步数据,这些都会动摇引导的稳定性。我更关心的是它是否支持当前框架、能不能跟随元素位置变化、支不支持滚动到目标元素、能不能自定义样式、支不支持异步页面内容、好不好记录完成状态、能不能跳过或重新开始、目标元素不存在时怎么处理——这几条比 demo 效果更能决定它能不能扛住真实业务。

举个例子,某一步要高亮"成员管理"按钮,但当前用户没权限看到它,库本身不懂你的业务逻辑。你的引导流程必须能跳过这一步,或换成适合当前权限的内容——这部分成本,库不替你付。

滚动容器也得专门看。后台系统经常不是 window 滚动,而是内容区、弹窗、抽屉内部滚动。库要是只会 window.scrollTo,目标在内层容器里时定位就飘。这个坑 demo 里几乎不暴露,真实页面里很常见。

高亮框的定位方式也值得看一眼。有的库靠监听 scrollresize 事件重算位置,页面一动就重新读 getBoundingClientRect;也有的开始用 ResizeObserverIntersectionObserver 这类更省心的观察器来跟随目标。后者在目标尺寸会变、或者目标可能被滚出可视区的场景里稳得多。挑库时我会翻一下它对位置跟随的实现,因为一旦目标元素在动画里或在虚拟列表里,纯事件监听那套很容易跟丢或者抖动。

主流的几个库我大致都摸过:有的偏轻量、只做高亮和气泡,遮罩靠一个带镂空的 SVG 或者四块 div 拼出来;有的把整套步骤状态机都包了,连"完成""跳过""重新开始"都给你管好。轻的灵活但要自己补的东西多,重的省事但定制起来容易顶到它的设计边界。选哪种,取决于你前面估的那些成本里,哪部分你更想自己攥在手里。

目标元素可能不存在:这是最容易翻车的成本

新手引导最容易栽的地方,是默认目标元素一定在。真实场景里它可能因为这些原因缺席:

  • 用户没有权限
  • 数据还没加载完
  • 菜单被折叠
  • 页面滚动容器没有定位到目标区域
  • 响应式布局下按钮位置变化
  • A/B 实验导致 DOM 结构不同

所以每一步开始前都得先探一探目标:

1function findStepTarget(selector) {
2  const element = document.querySelector(selector);
3
4  if (!element) {
5    return null;
6  }
7
8  return element;
9}
10
11function runStep(step) {
12  const target = findStepTarget(step.selector);
13
14  if (!target) {
15    return { skipped: true, reason: "target_not_found" };
16  }
17
18  target.scrollIntoView({ block: "center" });
19  return { skipped: false, target };
20}

目标不在,别让页面报错,也别甩一个漂在空白处的气泡出来。可以跳过、延迟重试,或结束引导并记下原因。

异步渲染最好设个重试上限。目标依赖接口数据时不能无限等,我一般在几秒内轮询几次,还找不到就跳过或结束,把原因记下:

1function waitForTarget(selector, { timeout = 3000, interval = 200 } = {}) {
2  return new Promise((resolve) => {
3    const start = Date.now();
4
5    const timer = setInterval(() => {
6      const el = document.querySelector(selector);
7      if (el) {
8        clearInterval(timer);
9        resolve(el);
10        return;
11      }
12      if (Date.now() - start > timeout) {
13        clearInterval(timer);
14        resolve(null); // 超时,交给上层决定跳过还是结束
15      }
16    }, interval);
17  });
18}

比轮询更省电的是用 MutationObserver 盯着 DOM 变化,元素一挂上来就触发,配一个超时兜底:轮询是"每隔一会问一次",观察器是"它来了叫我一声",后者在目标出现时机不确定时更跟手。两种都行,关键是必须有超时,硬等的结果是用户盯着一个一直加载的引导层,比没引导还糟。这类边界处理,才是新手引导真正吃工时的地方——比接个库重得多。

步骤要少而明确

产品常想把所有功能都塞进引导,于是第一步到第十步排下来,用户还没开始用系统就已经不耐烦。更好的做法是把范围收窄:只引导最关键的三到五步,每一步只讲一个动作,文案尽量短,允许用户随时跳过,完成后也不要反复打扰。文案别写成说明书。"点击这里可以进入项目创建页面,在这里你可以填写项目名称、描述和成员",不如"创建你的第一个项目"。前者解释功能,后者推动行动。

步骤之间还要留意状态残留。用户在第三步刷新了页面、或者中途点了别处跳转,引导应该能从合理的位置续上或干脆重来,而不是卡在一个高亮框漂在旧位置的半死状态。这类"用户不按你设计的路径走"的情况,测试时最好专门造几个:中途刷新、中途切路由、目标按钮在第二步之后才出现。真实用户永远比 demo 里那条顺滑的路径更野。

把步骤本身当成一个小状态机

写多了引导会发现,最容易出乱子的不是单个步骤,而是步骤之间的流转:上一步的高亮框有没有清干净、跳过时状态标没标对、目标找不到时该"跳过这一步"还是"结束整条引导"。这些如果散在各处 if 里,改两次就乱。我后来习惯把引导抽象成一个很小的状态机,每一步的推进、跳过、结束都走统一入口:

1function createGuide(steps) {
2  let index = 0;
3  const result = [];
4
5  async function next() {
6    if (index >= steps.length) {
7      return finish("completed");
8    }
9
10    const step = steps[index];
11    const target = await waitForTarget(step.selector);
12
13    if (!target) {
14      // 目标缺失:记录原因,跳到下一步,而不是崩在这
15      result.push({ step: step.key, skipped: true, reason: "target_not_found" });
16      index += 1;
17      return next();
18    }
19
20    target.scrollIntoView({ block: "center" });
21    highlight(target, step.tip);
22    result.push({ step: step.key, skipped: false });
23  }
24
25  function skip() {
26    clearHighlight();
27    index += 1;
28    return next();
29  }
30
31  function finish(reason) {
32    clearHighlight();
33    markGuideSeen();
34    report(result, reason);
35  }
36
37  return { start: next, next: () => { index += 1; return next(); }, skip, finish };
38}

关键在于:目标找不到时它自动跳过并记原因,而不是整条引导卡死或报错;每次推进都先 clearHighlight,避免上一步的镂空框留在页面上叠着。有了这个统一入口,前面说的那些异常情况——权限缺元素、异步没渲染、用户中途跳过——才有一个地方集中处理,而不是每个步骤各写一遍。这一层,正是库通常不替你管、又最影响体验的地方。

记录用户是否看过:本地不够,得上服务端

引导不能每次开页面都冒出来。最省事的是记在本地:

1const GUIDE_KEY = "project-create-guide-seen";
2
3export function shouldShowGuide() {
4  return localStorage.getItem(GUIDE_KEY) !== "1";
5}
6
7export function markGuideSeen() {
8  localStorage.setItem(GUIDE_KEY, "1");
9}

但本地存储只扛得住轻量场景。用户换浏览器、换设备,状态就丢了。后台系统或 SaaS 产品更稳的是把完成状态存到服务端,按用户、租户和功能版本区分:

1{
2  "userId": "u_123",
3  "guideKey": "project-create-guide",
4  "version": 2,
5  "completedAt": "2023-12-25T10:00:00Z"
6}

版本号很关键。功能改版后,旧用户可能得重新看一次引导,但不能污染已完成旧版本的记录。判断要不要给某个用户展示,实际是拿"他完成过的最高版本"和"当前引导版本"比:

1function shouldShow(record, currentVersion) {
2  if (!record) return true;                 // 从没看过
3  if (record.completedVersion < currentVersion) return true; // 有新版本
4  return false;                             // 已看过当前版本
5}

这样老用户在功能大改后能被重新引导一次,已经看过新版的又不会被反复打扰。本地存储那套顶多存个布尔,扛不住"按版本区分"这种需求,所以稍微正经点的产品,这笔服务端记账的账迟早要还——而它同样是选库时那份 demo 完全不会告诉你的成本。

触发时机同样得克制。用户刚登录、接口还没回、弹窗正开着、页面还在骨架屏时,都不适合马上弹。我会等核心内容稳定了再判断触不触发;用户已经在执行任务,就别硬插进去。

判断"核心内容稳定了没"也不难,通常是等主数据请求完成、关键区域首屏渲染结束之后再触发,而不是在组件一挂载就弹。有的团队会等一个全局的"页面就绪"事件,有的直接观察目标元素是否已经在视口里稳定存在几百毫秒。方式不重要,重要的是别让引导和页面加载抢时间——引导框比内容先出来,本身就是一种打扰。

不要阻塞用户完成任务

引导是帮用户行动的,不是替代说明书,所以必须能跳过、关闭、稍后再看。复杂后台还得顾到几处容易被忽略的可访问性细节:

  • 键盘操作:气泡弹出时焦点应该落到引导层里,Esc 能关闭,Tab 不该跑到被遮罩盖住的背景元素上(这需要一个焦点陷阱)
  • 屏幕阅读器:引导容器给上 role="dialog"aria-modal="true",提示文案能被读出来,而不是一片沉默
  • 移动端遮挡:小屏上气泡容易顶到屏幕边缘或被虚拟键盘盖住,定位要留出安全边距
  • 滚动锁定:遮罩期间锁 body 滚动,但别把内层真正要操作的滚动容器也一起锁死

这里有个很典型的矛盾:遮罩为了聚焦注意力会拦掉背景点击,可引导的最后一步偏偏又想让用户去点那个被高亮的按钮。处理不好,就是遮罩把用户真正要点的区域也挡住了,引导从帮助变成阻碍。常见解法是给高亮目标开一个"点击穿透"的洞,让镂空区域内的点击能落到真实按钮上,其余地方仍然被遮罩挡住。

引导收尾也别把用户丢在一个悬着的状态。最后一步提示"现在你可以创建项目了",那按钮最好就在眼前,或者自动聚焦到输入框。文案也要像产品操作,别像系统说明——少写"这里是项目管理入口,可用于创建、编辑、删除项目",多写"创建你的第一个项目"。用户要的是下一步动作,不是界面名词解释。

埋点:验证这份成本花得值不值

引导上线后,别只盯有没有报错。至少记这几个指标:

  • 展示次数
  • 完成率
  • 跳过率
  • 每一步流失率
  • 引导后关键动作完成率

展示很多、跳过率很高,多半是它打扰了用户;完成率高但关键动作没提升,说明引导可能只是被看完了,并没真正推动任务。这两种信号指向的改法完全不同:前者要砍步骤、挪时机,后者要重新想这几步到底有没有对准用户真正要做的动作。

埋点里我还会特意分步记录,而不是只记"整体完成没完成"。因为一条引导在哪一步开始掉人,往往比总完成率更有用——如果八成用户都卡在第二步跳出,那问题多半就出在第二步的文案或目标上,改一处就能救回一大截。埋点存在的意义,就是让你确认前面那一堆工程成本到底换来了什么——没有数据,你连它是帮助还是打扰都判断不了。

回到成本这件事

那次把 9 步压到 3 步之后,我对新手引导的成本结构看得更清了。选库解决的是遮罩、定位、步骤展示这一层,成本最低;真正贵的是这些库管不到的地方:

  • 引导目标到底对不对准用户当下要做的动作
  • 触发时机会不会和页面加载、正在进行的任务撞车
  • 权限差异导致某一步的目标根本不该出现
  • 异步渲染下目标元素的等待与超时
  • 完成状态怎么按用户、租户、版本记账
  • 可访问性和遮罩穿透这类体验细节

这几条加起来,才是新手引导真正的工作量所在。步骤越少、动作越明确、越贴近用户当下的任务,这份投入才越像帮助,而不是打扰。所以下次再接这类需求,我会先把这些隐性成本估进排期,再去挑库——顺序反了,交付时一定会返工。