动态元素与异步引导

真实业务页面经常不是一次性渲染完成的。数据可能要等接口返回,面板可能点击后才出现,列表项可能由虚拟滚动生成。Driver.js 引导这类页面时,要先保证目标元素存在。

等待元素出现

可以写一个简单的等待函数:

js
function waitForElement(selector, timeout = 3000) {
  return new Promise((resolve, reject) => {
    const existing = document.querySelector(selector);
    if (existing) {
      resolve(existing);
      return;
    }

    const observer = new MutationObserver(() => {
      const element = document.querySelector(selector);
      if (element) {
        observer.disconnect();
        resolve(element);
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });

    window.setTimeout(() => {
      observer.disconnect();
      reject(new Error(`Element not found: ${selector}`));
    }, timeout);
  });
}

启动导览前等待关键元素:

js
async function startDashboardTour() {
  await waitForElement("#project-list");

  const driverObj = driver({
    steps: [
      {
        element: "#project-list",
        popover: {
          title: "项目列表",
          description: "接口加载完成后,这里会显示项目。",
        },
      },
    ],
  });

  driverObj.drive();
}

代码演示

下面的演示会先打开一个动态面板,再进入下一步高亮新出现的设置项。

在线代码演示
源码
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Driver.js 动态元素演示</title>
  <link rel="stylesheet" href="/driver-js/vendor/driver.css">
  <style>
    body {
      margin: 0;
      background: #f1f5f9;
      color: #172033;
      font-family: Arial, "Microsoft YaHei", sans-serif;
    }
.page {
  max-width: 780px;
  margin: 0 auto;
  padding: 26px 22px;
}

.header,
.drawer {
  border: 1px solid #dbe3ef;
  border-radius: 8px;
  background: #fff;
  box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
}

.header {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 18px;
  padding: 16px;
}

button {
  cursor: pointer;
  border-radius: 6px;
  padding: 9px 13px;
  font-weight: 700;
}

.primary {
  border: 1px solid #2563eb;
  background: #2563eb;
  color: #fff;
}

.secondary {
  border: 1px solid #cbd5e1;
  background: #fff;
  color: #334155;
}

.drawer {
  display: none;
  padding: 18px;
}

.drawer.open {
  display: block;
}

.setting {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  margin-top: 14px;
  border-top: 1px solid #e2e8f0;
  padding-top: 14px;
  color: #334155;
}

.switch {
  width: 46px;
  height: 24px;
  border-radius: 999px;
  background: #2563eb;
  padding: 3px;
}

.dot {
  width: 18px;
  height: 18px;
  margin-left: 20px;
  border-radius: 50%;
  background: #fff;
}

</style> </head> <body> <main class="page"> <section class="header"> <div> <strong>团队设置</strong> <div>下一步会先打开设置面板,再高亮动态出现的通知开关。</div> </div> <div> <button id="open-settings" class="secondary" type="button">打开设置</button> <button id="start-async-tour" class="primary" type="button">异步引导</button> </div> </section>

&lt;section id=&quot;settings-drawer&quot; class=&quot;drawer&quot;&gt;
  &lt;strong&gt;设置面板&lt;/strong&gt;
  &lt;div id=&quot;notification-setting&quot; class=&quot;setting&quot;&gt;
    &lt;span&gt;重要通知&lt;/span&gt;
    &lt;span class=&quot;switch&quot;&gt;&lt;span class=&quot;dot&quot;&gt;&lt;/span&gt;&lt;/span&gt;
  &lt;/div&gt;
&lt;/section&gt;

</main>

<script type="module"> import { driver } from "/driver-js/vendor/driver.js.mjs";

function openSettingsDrawer() {
  return new Promise((resolve) =&gt; {
    window.setTimeout(() =&gt; {
      document.querySelector(&quot;#settings-drawer&quot;).classList.add(&quot;open&quot;);
      resolve();
    }, 450);
  });
}

function waitForElement(selector) {
  return new Promise((resolve) =&gt; {
    const existing = document.querySelector(selector);
    if (existing) {
      resolve(existing);
      return;
    }

    const observer = new MutationObserver(() =&gt; {
      const element = document.querySelector(selector);
      if (element) {
        observer.disconnect();
        resolve(element);
      }
    });

    observer.observe(document.body, { childList: true, subtree: true });
  });
}

document.querySelector(&quot;#open-settings&quot;).addEventListener(&quot;click&quot;, openSettingsDrawer);

document.querySelector(&quot;#start-async-tour&quot;).addEventListener(&quot;click&quot;, () =&gt; {
  const driverObj = driver({
    nextBtnText: &quot;下一步&quot;,
    prevBtnText: &quot;上一步&quot;,
    doneBtnText: &quot;完成&quot;,
    steps: [
      {
        element: &quot;#open-settings&quot;,
        popover: {
          title: &quot;打开设置&quot;,
          description: &quot;下一步会模拟异步操作,先打开设置面板。&quot;,
          onNextClick: async () =&gt; {
            await openSettingsDrawer();
            await waitForElement(&quot;#notification-setting&quot;);
            driverObj.moveNext();
          },
        },
      },
      {
        element: &quot;#notification-setting&quot;,
        popover: {
          title: &quot;动态出现的元素&quot;,
          description: &quot;等面板渲染完成后,再进入这个步骤,避免找不到目标元素。&quot;,
          side: &quot;top&quot;,
          align: &quot;start&quot;,
        },
      },
    ],
  });

  driverObj.drive();
});

</script> </body> </html>

引导需要打开的面板

如果下一步元素在抽屉、弹窗或折叠菜单里,应在步骤切换前先打开它。

js
const driverObj = driver({
  steps: [
    {
      element: "#settings-button",
      popover: {
        title: "设置",
        description: "点击后会打开设置抽屉。",
        onNextClick: async () => {
          await openSettingsDrawer();
          await waitForElement("#notification-setting");
          driverObj.moveNext();
        },
      },
    },
    {
      element: "#notification-setting",
      popover: {
        title: "通知设置",
        description: "这里可以调整消息通知方式。",
      },
    },
  ],
});

接管 onNextClick 后,要在异步操作完成后手动调用 moveNext()

权限差异

不同用户可能看到不同元素。生成步骤时应根据权限过滤:

js
const steps = [
  {
    element: "#project-list",
    popover: {
      title: "项目列表",
      description: "这里展示与你相关的项目。",
    },
  },
];

if (user.isAdmin) {
  steps.push({
    element: "#team-settings",
    popover: {
      title: "团队设置",
      description: "管理员可以在这里配置团队权限。",
    },
  });
}

driver({ steps }).drive();

不要让普通用户进入只有管理员才有的步骤。

标签页和折叠区

目标元素在隐藏标签页中时,应先切换标签:

js
async function showBillingTab() {
  document.querySelector("#billing-tab").click();
  await waitForElement("#invoice-list");
}

const driverObj = driver({
  steps: [
    {
      element: "#billing-tab",
      popover: {
        title: "账单",
        description: "打开账单标签页查看发票。",
        onNextClick: async () => {
          await showBillingTab();
          driverObj.moveNext();
        },
      },
    },
    {
      element: "#invoice-list",
      popover: {
        title: "发票列表",
        description: "已开具的发票会显示在这里。",
      },
    },
  ],
});

虚拟列表

虚拟列表只渲染当前视口附近的元素。不要把导览目标绑定到一个可能不存在的列表项上。

更稳妥的做法是:

  • 高亮列表容器,而不是某个随机列表项。
  • 高亮固定的表头、筛选器或操作栏。
  • 如果必须说明列表项,先滚动到确定位置,再等待元素出现。

失败时降级

如果目标元素找不到,不要让页面卡住。可以给出兜底提示或跳过步骤:

js
async function safeStartTour() {
  try {
    await waitForElement("#create-project");
    driver({ steps }).drive();
  } catch {
    console.warn("页面引导目标不存在,已跳过本次导览。");
  }
}

页面引导是辅助体验,不能影响主流程。