动态元素与异步引导
真实业务页面经常不是一次性渲染完成的。数据可能要等接口返回,面板可能点击后才出现,列表项可能由虚拟滚动生成。Driver.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);
});
}
启动导览前等待关键元素:
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>
<section id="settings-drawer" class="drawer">
<strong>设置面板</strong>
<div id="notification-setting" class="setting">
<span>重要通知</span>
<span class="switch"><span class="dot"></span></span>
</div>
</section>
</main>
<script type="module">
import { driver } from "/driver-js/vendor/driver.js.mjs";
function openSettingsDrawer() {
return new Promise((resolve) => {
window.setTimeout(() => {
document.querySelector("#settings-drawer").classList.add("open");
resolve();
}, 450);
});
}
function waitForElement(selector) {
return new Promise((resolve) => {
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 });
});
}
document.querySelector("#open-settings").addEventListener("click", openSettingsDrawer);
document.querySelector("#start-async-tour").addEventListener("click", () => {
const driverObj = driver({
nextBtnText: "下一步",
prevBtnText: "上一步",
doneBtnText: "完成",
steps: [
{
element: "#open-settings",
popover: {
title: "打开设置",
description: "下一步会模拟异步操作,先打开设置面板。",
onNextClick: async () => {
await openSettingsDrawer();
await waitForElement("#notification-setting");
driverObj.moveNext();
},
},
},
{
element: "#notification-setting",
popover: {
title: "动态出现的元素",
description: "等面板渲染完成后,再进入这个步骤,避免找不到目标元素。",
side: "top",
align: "start",
},
},
],
});
driverObj.drive();
});
</script>
</body>
</html>
引导需要打开的面板
如果下一步元素在抽屉、弹窗或折叠菜单里,应在步骤切换前先打开它。
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()。
权限差异
不同用户可能看到不同元素。生成步骤时应根据权限过滤:
const steps = [
{
element: "#project-list",
popover: {
title: "项目列表",
description: "这里展示与你相关的项目。",
},
},
];
if (user.isAdmin) {
steps.push({
element: "#team-settings",
popover: {
title: "团队设置",
description: "管理员可以在这里配置团队权限。",
},
});
}
driver({ steps }).drive();
不要让普通用户进入只有管理员才有的步骤。
标签页和折叠区
目标元素在隐藏标签页中时,应先切换标签:
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: "已开具的发票会显示在这里。",
},
},
],
});
虚拟列表
虚拟列表只渲染当前视口附近的元素。不要把导览目标绑定到一个可能不存在的列表项上。
更稳妥的做法是:
- 高亮列表容器,而不是某个随机列表项。
- 高亮固定的表头、筛选器或操作栏。
- 如果必须说明列表项,先滚动到确定位置,再等待元素出现。
失败时降级
如果目标元素找不到,不要让页面卡住。可以给出兜底提示或跳过步骤:
async function safeStartTour() {
try {
await waitForElement("#create-project");
driver({ steps }).drive();
} catch {
console.warn("页面引导目标不存在,已跳过本次导览。");
}
}
页面引导是辅助体验,不能影响主流程。