# 动态元素与异步引导

URL: https://caijiao.org/driver-js/05-practices/01-async-and-dynamic
Source: docs/driver-js/05-practices/01-async-and-dynamic.md
Description: 讲解 Driver.js 在异步数据、懒加载组件、弹窗、标签页、权限控制和虚拟列表中的引导处理方法。

真实业务页面经常不是一次性渲染完成的。数据可能要等接口返回，面板可能点击后才出现，列表项可能由虚拟滚动生成。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();
}
```

## 代码演示

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

[在线代码演示](/driver-js/demos/5-async.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("页面引导目标不存在，已跳过本次导览。");
  }
}
```

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