pin_drop当前位置:知识文库 ❯ 图文

JS集成:HTML onfocus与on - 详细教程与实战指南

一、教程简介

焦点(Focus)是Web无障碍和键盘导航的核心概念。当元素获得焦点时,它可以接收键盘输入和其他交互;当元素失去焦点时,用户需要重新聚焦才能操作。onfocusonblur事件是焦点管理的基石,它们不仅影响交互逻辑,更直接关系到Web应用的无障碍可用性。本教程将全面讲解HTML焦点事件的类型、焦点管理API、tabIndex属性、焦点陷阱的实现、模态框的焦点处理,以及无障碍焦点管理的最佳实践,帮助开发者构建对所有用户友好的交互体验。


二、核心概念

四种焦点事件

事件 冒泡 事件对象 说明
focus FocusEvent 元素获得焦点时触发
blur FocusEvent 元素失去焦点时触发
focusin FocusEvent 元素获得焦点时触发(冒泡版本)
focusout FocusEvent 元素失去焦点时触发(冒泡版本)

关键区别:focusblur不冒泡,只能在目标元素上监听;focusinfocusout会冒泡,可以在父元素上使用事件委托。

可获得焦点的元素

以下元素默认可以获得焦点(无需设置tabIndex):

  • 表单元素<input><textarea><select><button>

  • 链接元素<a href="...">(有href属性的链接)

  • 区域元素<area href="...">

  • 摘要元素<summary>(details的摘要)

  • 可编辑元素:具有contenteditable="true"的元素

  • 自定义元素:设置了tabIndex属性的任何元素

FocusEvent对象

代码示例

element.addEventListener('focus', function(event) {
    event.type;           // 'focus'
    event.target;         // 获得焦点的元素
    event.relatedTarget;  // 失去焦点的元素(focus事件中)
});

element.addEventListener('blur', function(event) {
    event.type;           // 'blur'
    event.target;         // 失去焦点的元素
    event.relatedTarget;  // 获得焦点的元素(blur事件中)
});

三、语法与用法

基本用法

代码示例

// focus/blur - 不冒泡
element.addEventListener('focus', function(event) { /* 获得焦点 */ });
element.addEventListener('blur', function(event) { /* 失去焦点 */ });

// focusin/focusout - 冒泡(推荐用于事件委托)
parent.addEventListener('focusin', function(event) { /* 子元素获得焦点 */ });
parent.addEventListener('focusout', function(event) { /* 子元素失去焦点 */ });

焦点管理API

代码示例

// 设置焦点
element.focus();                    // 聚焦元素
element.focus({ preventScroll: true }); // 聚焦但不滚动到元素

// 移除焦点
element.blur();                     // 移除焦点

// 获取当前焦点元素
document.activeElement;             // 当前获得焦点的元素

// 判断元素是否可获得焦点
element.tabIndex;                   // -1表示不可Tab聚焦,0+表示可聚焦

tabIndex属性

tabIndex值 行为
未设置 默认可聚焦元素可Tab聚焦,其他不可
-1 可通过编程方式聚焦,但Tab键跳过
0 可Tab聚焦,按文档顺序
正数 可Tab聚焦,按数值从小到大排序(不推荐使用)

四、代码示例

示例1:焦点事件类型对比

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>焦点事件类型对比</title>
    <style>
        body {
            font-family: 'Microsoft YaHei', sans-serif;
            max-width: 800px;
            margin: 40px auto;
            padding: 20px;
        }
        .demo-section {
            background: white;
            border-radius: 10px;
            padding: 20px;
            margin: 20px 0;
            box-shadow: 0 2px 10px rgba(0,0,0,0.08);
        }
        .form-grid {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 12px;
            margin: 16px 0;
        }
        .form-grid input {
            width: 100%;
            padding: 10px 14px;
            border: 2px solid #e0e0e0;
            border-radius: 8px;
            font-size: 14px;
            box-sizing: border-box;
            outline: none;
            transition: all 0.2s;
        }
        .form-grid input:focus {
            border-color: #2196F3;
            box-shadow: 0 0 0 3px rgba(33,150,243,0.15);
        }
        .form-grid input.focused {
            border-color: #4CAF50;
            background: #f0fff0;
        }
        .form-grid input.blurred {
            border-color: #f44336;
            background: #fff0f0;
        }
        .event-log {
            background: #263238;
            color: #ECEFF1;
            padding: 14px;
            border-radius: 8px;
            font-family: 'Consolas', monospace;
            font-size: 12px;
            max-height: 300px;
            overflow-y: auto;
        }
        .log-focus { color: #66BB6A; }
        .log-blur { color: #EF5350; }
        .log-focusin { color: #42A5F5; }
        .log-focusout { color: #FFA726; }
    </style>
</head>
<body>
    <h1>焦点事件类型对比</h1>

    <div class="demo-section">
        <h3>交互演示:使用Tab键在输入框间切换</h3>
        <form id="demoForm">
            <div class="form-grid">
                <input type="text" name="name" placeholder="姓名" id="input1">
                <input type="email" name="email" placeholder="邮箱" id="input2">
                <input type="tel" name="phone" placeholder="电话" id="input3">
                <input type="text" name="address" placeholder="地址" id="input4">
            </div>
        </form>
        <p style="font-size:13px;color:#666;">点击输入框或按Tab键切换焦点,观察事件日志</p>
        <div class="event-log" id="eventLog"></div>
    </div>

    <script>
        const form = document.getElementById('demoForm');
        const eventLog = document.getElementById('eventLog');

        function addLog(msg, type) {
            const time = new Date().toLocaleTimeString('zh-CN', { hour12: false });
            eventLog.innerHTML += '<div class="' + type + '">[' + time + '] ' + msg + '</div>';
            eventLog.scrollTop = eventLog.scrollHeight;
        }

        // 使用focusin/focusout(冒泡)在表单上监听
        form.addEventListener('focusin', function(event) {
            addLog('focusin: ' + (event.target.name || event.target.id) + ' 获得焦点' +
                (event.relatedTarget ? ' (来自: ' + (event.relatedTarget.name || '?') + ')' : ''), 'log-focusin');
            event.target.classList.add('focused');
            event.target.classList.remove('blurred');
        });

        form.addEventListener('focusout', function(event) {
            addLog('focusout: ' + (event.target.name || event.target.id) + ' 失去焦点' +
                (event.relatedTarget ? ' (前往: ' + (event.relatedTarget.name || '?') + ')' : ''), 'log-focusout');
            event.target.classList.remove('focused');
            event.target.classList.add('blurred');
            setTimeout(function() { event.target.classList.remove('blurred'); }, 500);
        });

        // focus/blur(不冒泡)需要直接绑定
        form.querySelectorAll('input').forEach(function(input) {
            input.addEventListener('focus', function() {
                addLog('focus: ' + this.name + ' 获得焦点(不冒泡,直接监听)', 'log-focus');
            });
            input.addEventListener('blur', function() {
                addLog('blur: ' + this.name + ' 失去焦点(不冒泡,直接监听)', 'log-blur');
            });
        });
    </script>
</body>
</html>

示例2:tabIndex与焦点管理

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>tabIndex与焦点管理</title>
    <style>
        body {
            font-family: 'Microsoft YaHei', sans-serif;
            max-width: 800px;
            margin: 40px auto;
            padding: 20px;
        }
        .demo-card {
            background: white;
            border-radius: 10px;
            padding: 20px;
            margin: 16px 0;
            box-shadow: 0 2px 10px rgba(0,0,0,0.08);
        }
        .tab-demo {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
            margin: 16px 0;
        }
        .tab-item {
            padding: 10px 20px;
            border: 2px solid #e0e0e0;
            border-radius: 8px;
            cursor: pointer;
            font-size: 14px;
            transition: all 0.2s;
            user-select: none;
        }
        .tab-item:focus {
            border-color: #2196F3;
            box-shadow: 0 0 0 3px rgba(33,150,243,0.2);
            outline: none;
        }
        .tab-item.tabindex-0 { background: #E8F5E9; border-color: #4CAF50; }
        .tab-item.tabindex-neg { background: #FFEBEE; border-color: #f44336; }
        .tab-item.tabindex-pos { background: #E3F2FD; border-color: #2196F3; }
        .focus-info {
            background: #f0f4f8;
            padding: 14px;
            border-radius: 8px;
            margin: 12px 0;
            font-size: 14px;
        }
        button {
            padding: 8px 16px;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 13px;
            margin: 4px;
        }
        .btn-blue { background: #2196F3; color: white; }
        .btn-green { background: #4CAF50; color: white; }
        .btn-orange { background: #FF9800; color: white; }
    </style>
</head>
<body>
    <h1>tabIndex与焦点管理</h1>

    <div class="demo-card">
        <h3>tabIndex值对比</h3>
        <p>按Tab键在元素间切换,观察不同tabIndex值的Tab顺序</p>

        <div class="tab-demo">
            <div class="tab-item tabindex-pos" tabIndex="3">tabIndex=3</div>
            <div class="tab-item tabindex-pos" tabIndex="1">tabIndex=1</div>
            <div class="tab-item tabindex-0" tabIndex="0">tabIndex=0</div>
            <div class="tab-item tabindex-pos" tabIndex="2">tabIndex=2</div>
            <div class="tab-item tabindex-neg" tabIndex="-1">tabIndex=-1</div>
            <div class="tab-item tabindex-0" tabIndex="0">tabIndex=0</div>
        </div>

        <div class="focus-info" id="focusInfo">
            当前焦点元素: <code id="currentFocus">无</code> |
            document.activeElement: <code id="activeElement">body</code>
        </div>
    </div>

    <div class="demo-card">
        <h3>编程式焦点管理</h3>
        <div>
            <button class="btn-blue" onclick="focusElement('target1')">聚焦到目标1</button>
            <button class="btn-blue" onclick="focusElement('target2')">聚焦到目标2</button>
            <button class="btn-green" onclick="focusElement('target3', true)">聚焦目标3(不滚动)</button>
            <button class="btn-orange" onclick="blurCurrent()">移除当前焦点</button>
        </div>

        <div style="margin-top:16px;">
            <input type="text" id="target1" placeholder="目标1" style="padding:8px 12px;border:2px solid #e0e0e0;border-radius:6px;font-size:14px;outline:none;margin:4px;">
            <input type="text" id="target2" placeholder="目标2" style="padding:8px 12px;border:2px solid #e0e0e0;border-radius:6px;font-size:14px;outline:none;margin:4px;">
        </div>

        <div id="target3" tabIndex="0" style="margin-top:8px;padding:12px;background:#f0f4f8;border-radius:6px;cursor:pointer;">
            目标3 - div元素(tabIndex=0使其可聚焦)
        </div>
    </div>

    <script>
        function updateFocusInfo() {
            const active = document.activeElement;
            document.getElementById('currentFocus').textContent =
                active.tagName + (active.id ? '#' + active.id : '') + (active.name ? '[name=' + active.name + ']' : '');
            document.getElementById('activeElement').textContent =
                active.tagName.toLowerCase() + (active.id ? '#' + active.id : '');
        }

        document.addEventListener('focusin', function() { updateFocusInfo(); });
        document.addEventListener('focusout', function() { setTimeout(updateFocusInfo, 10); });

        document.getElementById('target3').addEventListener('focus', function() {
            this.style.outline = '2px solid #2196F3';
            this.style.outlineOffset = '2px';
        });
        document.getElementById('target3').addEventListener('blur', function() {
            this.style.outline = 'none';
        });

        function focusElement(id, preventScroll) {
            const el = document.getElementById(id);
            if (el) {
                if (preventScroll) {
                    el.focus({ preventScroll: true });
                } else {
                    el.focus();
                }
            }
        }

        function blurCurrent() {
            const active = document.activeElement;
            if (active && active !== document.body) {
                active.blur();
            }
        }
    </script>
</body>
</html>

示例3:模态框焦点陷阱

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>模态框焦点陷阱</title>
    <style>
        body {
            font-family: 'Microsoft YaHei', sans-serif;
            max-width: 600px;
            margin: 40px auto;
            padding: 20px;
        }
        .btn-open {
            padding: 12px 24px;
            background: #2196F3;
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 16px;
            cursor: pointer;
        }
        .btn-open:hover { background: #1976D2; }
        .modal-overlay {
            position: fixed;
            top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(0,0,0,0.5);
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 1000;
            opacity: 0;
            visibility: hidden;
            transition: all 0.3s;
        }
        .modal-overlay.visible { opacity: 1; visibility: visible; }
        .modal {
            background: white;
            border-radius: 12px;
            padding: 30px;
            width: 90%;
            max-width: 450px;
            box-shadow: 0 10px 40px rgba(0,0,0,0.2);
        }
        .modal h2 { margin: 0 0 16px 0; }
        .modal input {
            width: 100%;
            padding: 10px 14px;
            border: 2px solid #e0e0e0;
            border-radius: 8px;
            font-size: 14px;
            box-sizing: border-box;
            outline: none;
            margin-bottom: 12px;
        }
        .modal input:focus { border-color: #2196F3; }
        .modal-actions {
            display: flex;
            gap: 10px;
            justify-content: flex-end;
            margin-top: 16px;
        }
        .modal-actions button {
            padding: 10px 20px;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-size: 14px;
        }
        .btn-cancel { background: #e0e0e0; color: #333; }
        .btn-confirm { background: #4CAF50; color: white; }
    </style>
</head>
<body>
    <h1>模态框焦点陷阱</h1>
    <p>打开模态框后,Tab键焦点被限制在模态框内部循环</p>

    <input type="text" placeholder="外部输入框1" style="padding:8px 12px;border:1px solid #ddd;border-radius:6px;margin:4px;">
    <input type="text" placeholder="外部输入框2" style="padding:8px 12px;border:1px solid #ddd;border-radius:6px;margin:4px;">
    <button class="btn-open" id="openModal">打开模态框</button>

    <div class="modal-overlay" id="modalOverlay" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
        <div class="modal">
            <h2 id="modalTitle">用户信息</h2>
            <label style="display:block;margin-bottom:4px;font-size:13px;color:#555;">姓名</label>
            <input type="text" id="modalName" placeholder="输入姓名">
            <label style="display:block;margin-bottom:4px;font-size:13px;color:#555;">邮箱</label>
            <input type="email" id="modalEmail" placeholder="输入邮箱">
            <div class="modal-actions">
                <button class="btn-cancel" id="cancelBtn">取消</button>
                <button class="btn-confirm" id="confirmBtn">确认</button>
            </div>
        </div>
    </div>

    <script>
        const openBtn = document.getElementById('openModal');
        const overlay = document.getElementById('modalOverlay');
        const cancelBtn = document.getElementById('cancelBtn');
        const confirmBtn = document.getElementById('confirmBtn');
        const modalName = document.getElementById('modalName');
        let lastFocusedElement = null;

        openBtn.addEventListener('click', function() {
            lastFocusedElement = document.activeElement;
            overlay.classList.add('visible');
            modalName.focus();
        });

        function closeModal() {
            overlay.classList.remove('visible');
            if (lastFocusedElement) {
                lastFocusedElement.focus();
            }
        }

        cancelBtn.addEventListener('click', closeModal);
        confirmBtn.addEventListener('click', function() {
            console.log('确认:', modalName.value);
            closeModal();
        });

        overlay.addEventListener('click', function(e) {
            if (e.target === overlay) closeModal();
        });

        document.addEventListener('keydown', function(e) {
            if (e.key === 'Escape' && overlay.classList.contains('visible')) {
                closeModal();
            }
        });

        // 焦点陷阱:Tab键在模态框内循环
        overlay.addEventListener('keydown', function(e) {
            if (e.key !== 'Tab') return;
            const focusableSelectors = 'input, button, select, textarea, [tabindex]:not([tabindex="-1"])';
            const focusableElements = overlay.querySelectorAll(focusableSelectors);
            const firstFocusable = focusableElements[0];
            const lastFocusable = focusableElements[focusableElements.length - 1];

            if (e.shiftKey) {
                if (document.activeElement === firstFocusable) {
                    e.preventDefault();
                    lastFocusable.focus();
                }
            } else {
                if (document.activeElement === lastFocusable) {
                    e.preventDefault();
                    firstFocusable.focus();
                }
            }
        });
    </script>
</body>
</html>

示例4:无障碍焦点管理

代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>无障碍焦点管理</title>
    <style>
        body {
            font-family: 'Microsoft YaHei', sans-serif;
            max-width: 800px;
            margin: 40px auto;
            padding: 20px;
        }
        .a11y-card {
            background: white;
            border-radius: 10px;
            padding: 20px;
            margin: 16px 0;
            box-shadow: 0 2px 10px rgba(0,0,0,0.08);
        }
        .a11y-card h3 { margin-top: 0; }
        .custom-widget {
            display: inline-flex;
            align-items: center;
            gap: 8px;
            padding: 8px 16px;
            background: #f0f4f8;
            border: 2px solid #e0e0e0;
            border-radius: 8px;
            cursor: pointer;
            user-select: none;
            margin: 4px;
        }
        .custom-widget:focus {
            border-color: #2196F3;
            box-shadow: 0 0 0 3px rgba(33,150,243,0.2);
            outline: none;
        }
        .custom-widget[aria-pressed="true"] {
            background: #E3F2FD;
            border-color: #2196F3;
        }
        .skip-link {
            position: absolute;
            top: -40px;
            left: 0;
            background: #2196F3;
            color: white;
            padding: 8px 16px;
            z-index: 100;
            transition: top 0.2s;
        }
        .skip-link:focus { top: 0; }
        .toast {
            position: fixed;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%) translateY(100px);
            background: #333;
            color: white;
            padding: 12px 24px;
            border-radius: 8px;
            transition: transform 0.3s;
            z-index: 100;
        }
        .toast.visible { transform: translateX(-50%) translateY(0); }
        .focus-ring-demo {
            display: flex;
            gap: 12px;
            flex-wrap: wrap;
            margin: 12px 0;
        }
        .focus-ring-demo button {
            padding: 10px 20px;
            border: 2px solid #e0e0e0;
            border-radius: 8px;
            background: white;
            cursor: pointer;
            font-size: 14px;
        }
        .focus-ring-demo button:focus { outline: none; }
        .focus-ring-demo button:focus-visible {
            outline: 3px solid #2196F3;
            outline-offset: 2px;
        }
        .focus-ring-demo button:hover { background: #f5f5f5; }
        .tip-box {
            background: #E8F5E9;
            border-left: 4px solid #4CAF50;
            padding: 12px 16px;
            border-radius: 0 8px 8px 0;
            margin: 12px 0;
            font-size: 14px;
        }
    </style>
</head>
<body>
    <a href="#main-content" class="skip-link">跳到主要内容</a>
    <h1>无障碍焦点管理</h1>

    <div class="a11y-card">
        <h3>1. 自定义组件的焦点管理</h3>
        <div class="custom-widget" tabIndex="0" role="button" aria-pressed="false" id="toggleWidget1">
            开关按钮
        </div>
        <div class="tip-box">
            自定义组件必须设置: tabIndex="0"(可聚焦)、role属性(语义)、键盘事件处理(Enter/Space)
        </div>
    </div>

    <div class="a11y-card" id="main-content">
        <h3>2. focus-visible 焦点样式</h3>
        <p>使用 :focus-visible 只在键盘导航时显示焦点环</p>
        <div class="focus-ring-demo">
            <button>按钮1</button>
            <button>按钮2</button>
            <button>按钮3</button>
        </div>
        <div class="tip-box">
            鼠标点击不显示焦点环,键盘Tab导航时显示焦点环。这是最佳实践!
        </div>
    </div>

    <div class="a11y-card">
        <h3>3. 动态内容的焦点管理</h3>
        <button onclick="addNotification()" style="padding:8px 16px;border:none;border-radius:6px;cursor:pointer;background:#2196F3;color:white;">添加通知</button>
        <div id="notificationArea" style="margin-top:12px;"></div>
    </div>

    <div class="toast" id="toast"></div>

    <script>
        document.querySelectorAll('.custom-widget').forEach(function(widget) {
            widget.addEventListener('keydown', function(e) {
                if (e.key === 'Enter' || e.key === ' ') {
                    e.preventDefault();
                    toggleWidget(this);
                }
            });
            widget.addEventListener('click', function() { toggleWidget(this); });
            widget.addEventListener('focus', function() {
                this.style.outline = '3px solid #2196F3';
                this.style.outlineOffset = '2px';
            });
            widget.addEventListener('blur', function() { this.style.outline = 'none'; });
        });

        function toggleWidget(el) {
            const isPressed = el.getAttribute('aria-pressed') === 'true';
            el.setAttribute('aria-pressed', !isPressed);
            showToast(isPressed ? '已关闭' : '已开启');
        }

        let notifCount = 0;
        function addNotification() {
            notifCount++;
            const area = document.getElementById('notificationArea');
            const notif = document.createElement('div');
            notif.style.cssText = 'padding:12px 16px;background:#E3F2FD;border-radius:8px;margin:8px 0;display:flex;justify-content:space-between;align-items:center;';
            notif.setAttribute('role', 'alert');
            notif.innerHTML = '<span>通知 #' + notifCount + '</span>' +
                '<button onclick="dismissNotification(this)" style="padding:4px 12px;border:none;border-radius:4px;cursor:pointer;background:#f44336;color:white;font-size:12px;">关闭</button>';
            area.prepend(notif);
            notif.querySelector('button').focus();
        }

        function dismissNotification(btn) {
            const notif = btn.parentElement;
            notif.style.opacity = '0';
            notif.style.transition = 'opacity 0.3s';
            setTimeout(function() {
                notif.remove();
                const nextBtn = document.querySelector('#notificationArea button');
                if (nextBtn) nextBtn.focus();
            }, 300);
        }

        function showToast(message) {
            const toast = document.getElementById('toast');
            toast.textContent = message;
            toast.classList.add('visible');
            setTimeout(function() { toast.classList.remove('visible'); }, 2000);
        }
    </script>
</body>
</html>

五、浏览器兼容性

特性 Chrome Firefox Safari Edge IE
focus/blur事件 全部 全部 全部 全部 全部
focusin/focusout事件 1+ 52+ 5.1+ 12+ 9+
FocusEvent.relatedTarget 1+ 1+ 1+ 12+ 9+
element.focus() 全部 全部 全部 全部 全部
focus({preventScroll}) 64+ 68+ 15.4+ 17+ 不支持
document.activeElement 1+ 1+ 1+ 12+ 6+
tabIndex 全部 全部 全部 全部 全部
:focus-visible 86+ 85+ 15.4+ 86+ 不支持
autofocus属性 全部 全部 全部 全部 10+
aria-modal 全部 全部 全部 全部 不支持

IE兼容性说明:IE9以下focusin/focusout不支持冒泡;IE不支持focus({preventScroll})选项;IE不支持:focus-visible伪类。


六、注意事项与最佳实践

1. 使用focusin/focusout进行事件委托

代码示例

// 不推荐:focus/blur不冒泡,无法委托
parent.addEventListener('focus', handler); // 无效

// 推荐:使用focusin/focusout
parent.addEventListener('focusin', function(event) {
    console.log('子元素获得焦点:', event.target);
});

2. 模态框必须实现焦点陷阱

代码示例

// 模态框打开时:
// 1. 保存当前焦点元素
// 2. 将焦点移到模态框内第一个可聚焦元素
// 3. Tab/Shift+Tab在模态框内循环
// 4. 关闭时恢复之前的焦点

3. 使用:focus-visible而非:focus设置焦点样式

代码示例

/* 不推荐:鼠标点击也显示焦点环 */
button:focus { outline: 3px solid blue; }

/* 推荐:只在键盘导航时显示焦点环 */
button:focus-visible { outline: 3px solid blue; }

/* 兼容方案 */
button:focus { outline: 3px solid blue; }
button:focus:not(:focus-visible) { outline: none; }

4. 自定义交互组件必须支持键盘

代码示例

// 自定义按钮组件
customBtn.addEventListener('keydown', function(e) {
    if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        this.click();
    }
});

5. 动态内容需要管理焦点

代码示例

// 添加新内容后,将焦点移到新内容
function addItem() {
    const item = createItem();
    list.prepend(item);
    item.querySelector('button').focus(); // 聚焦到新项
}

// 删除内容后,将焦点移到相邻项
function removeItem(btn) {
    const item = btn.closest('.item');
    const nextItem = item.nextElementSibling || item.previousElementSibling;
    item.remove();
    if (nextItem) nextItem.querySelector('button').focus();
}

6. 避免使用正数tabIndex

代码示例

<!-- 不推荐:正数tabIndex打乱自然Tab顺序 -->
<div tabIndex="1">第一</div>
<div tabIndex="3">第三</div>
<div tabIndex="2">第二</div>

<!-- 推荐:使用0或-1,通过DOM顺序控制 -->
<div tabIndex="0">第一</div>
<div tabIndex="0">第二</div>
<div tabIndex="0">第三</div>

七、代码规范示例

代码示例

/**
 * 焦点陷阱管理器 - 模态框焦点管理
 */
class FocusTrap {
    constructor(container, options = {}) {
        this.container = container;
        this.options = {
            onEscape: null,
            initialFocus: null,
            returnFocus: true,
            ...options
        };

        this.previousFocus = null;
        this.focusableSelectors = [
            'a[href]',
            'button:not([disabled])',
            'input:not([disabled])',
            'select:not([disabled])',
            'textarea:not([disabled])',
            '[tabindex]:not([tabindex="-1"])'
        ].join(', ');

        this._handleKeyDown = this._handleKeyDown.bind(this);
    }

    activate() {
        this.previousFocus = document.activeElement;
        document.addEventListener('keydown', this._handleKeyDown);

        const initialEl = this.options.initialFocus
            ? this.container.querySelector(this.options.initialFocus)
            : this._getFocusableElements()[0];

        if (initialEl) {
            setTimeout(function() { initialEl.focus(); }, 0);
        }
    }

    deactivate() {
        document.removeEventListener('keydown', this._handleKeyDown);

        if (this.options.returnFocus && this.previousFocus) {
            this.previousFocus.focus();
            this.previousFocus = null;
        }
    }

    _getFocusableElements() {
        return Array.from(this.container.querySelectorAll(this.focusableSelectors))
            .filter(function(el) { return el.offsetParent !== null; });
    }

    _handleKeyDown(event) {
        if (event.key === 'Escape' && this.options.onEscape) {
            this.options.onEscape();
            return;
        }

        if (event.key !== 'Tab') return;

        const focusableElements = this._getFocusableElements();
        if (focusableElements.length === 0) {
            event.preventDefault();
            return;
        }

        const firstElement = focusableElements[0];
        const lastElement = focusableElements[focusableElements.length - 1];

        if (event.shiftKey) {
            if (document.activeElement === firstElement) {
                event.preventDefault();
                lastElement.focus();
            }
        } else {
            if (document.activeElement === lastElement) {
                event.preventDefault();
                firstElement.focus();
            }
        }
    }
}

// 使用示例
const modal = document.getElementById('modal');
const focusTrap = new FocusTrap(modal, {
    initialFocus: 'input:first-of-type',
    onEscape: closeModal
});

function openModal() {
    modal.style.display = 'block';
    focusTrap.activate();
}

function closeModal() {
    modal.style.display = 'none';
    focusTrap.deactivate();
}

八、常见问题与解决方案

问题1:focus/blur不冒泡导致无法使用事件委托

代码示例

// 问题:focus/blur不冒泡
parent.addEventListener('focus', handler); // 不触发

// 解决方案1:使用focusin/focusout
parent.addEventListener('focusin', handler);

// 解决方案2:使用capture阶段
parent.addEventListener('focus', handler, true);

问题2:模态框打开后焦点跑到外部

代码示例

// 问题:Tab键可以将焦点移到模态框外部
// 解决方案:实现焦点陷阱
// 关键:监听Tab键,在首尾元素间循环

overlay.addEventListener('keydown', function(e) {
    if (e.key !== 'Tab') return;
    const focusable = overlay.querySelectorAll(
        'input, button, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
    }
});

问题3:动态添加的元素无法获得焦点

代码示例

// 问题:动态创建的div默认不可聚焦
const div = document.createElement('div');
div.focus(); // 无效

// 解决方案:设置tabIndex
div.tabIndex = 0;
div.focus(); // 有效

// 或使用role属性增强语义
div.setAttribute('role', 'button');
div.setAttribute('tabIndex', '0');

问题4:focus()导致页面滚动

代码示例

// 问题:element.focus()会滚动到元素位置
element.focus(); // 页面可能滚动

// 解决方案:使用preventScroll选项
element.focus({ preventScroll: true });

// 兼容方案
const x = window.scrollX, y = window.scrollY;
element.focus();
window.scrollTo(x, y);

问题5:relatedTarget为null

代码示例

// 问题:某些情况下event.relatedTarget为null
// - 点击页面空白区域导致失焦
// - 焦点移出浏览器窗口
// - 某些浏览器的安全限制

// 解决方案:检查relatedTarget是否为null
element.addEventListener('blur', function(event) {
    if (event.relatedTarget === null) {
        // 焦点移到了未知位置或窗口外
        console.log('焦点离开了当前元素,目标未知');
    }
});

九、总结

焦点管理是Web无障碍和键盘导航的核心,掌握其各个方面对于构建可访问的Web应用至关重要:

  • 四种焦点事件focus/blur不冒泡,适合单个元素监听;focusin/focusout冒泡,适合事件委托和表单级焦点管理。

  • tabIndex属性tabIndex="0"使元素可Tab聚焦,tabIndex="-1"允许编程聚焦但跳过Tab,避免使用正数tabIndex。

  • 焦点陷阱:模态框必须实现焦点陷阱,确保Tab键在模态框内循环,关闭时恢复之前的焦点。

  • 无障碍焦点管理:自定义交互组件必须设置tabIndex和ARIA属性,支持键盘操作(Enter/Space),使用:focus-visible区分鼠标和键盘焦点。

  • 动态内容焦点:添加新内容后将焦点移到新内容,删除内容后将焦点移到相邻项,使用role="alert"让屏幕阅读器播报动态变化。

  • 编程式焦点管理:使用element.focus()设置焦点,element.blur()移除焦点,document.activeElement获取当前焦点元素,注意focus({preventScroll})的兼容性。

常见问题

focus和focusin事件有什么区别?

focus和focusin都在元素获得焦点时触发,核心区别在于冒泡行为。focus不冒泡,只能在目标元素上直接监听;focusin会冒泡,可以在父元素上使用事件委托监听子元素的焦点变化。因此需要事件委托时应使用focusin/focusout。

tabIndex=-1和tabIndex=0有什么区别?

tabIndex=0使元素可以通过Tab键聚焦,按照文档顺序参与Tab导航;tabIndex=-1使元素可以通过JavaScript编程方式聚焦(如element.focus()),但Tab键会跳过该元素。通常用tabIndex=0让自定义组件可访问,用tabIndex=-1实现编程式焦点管理。

模态框为什么需要焦点陷阱?

模态框打开时,视觉焦点被限制在模态框内,如果键盘焦点可以Tab到模态框外部的元素,会造成键盘用户和屏幕阅读器用户的困惑。焦点陷阱确保Tab和Shift+Tab在模态框内的可聚焦元素间循环,关闭模态框后还需要恢复之前的焦点位置。

:focus-visible和:focus有什么区别?

:focus在元素获得焦点时始终触发,包括鼠标点击和键盘导航;:focus-visible只在用户通过键盘导航(如Tab键)获得焦点时触发,鼠标点击时不触发。使用:focus-visible可以避免鼠标点击时出现不必要的焦点环,同时保留键盘用户的焦点指示,是最佳实践。

如何避免focus()导致页面滚动?

使用focus方法的preventScroll选项:element.focus({ preventScroll: true }),这样聚焦元素时不会自动滚动页面。对于不支持该选项的旧浏览器,可以在focus后立即使用window.scrollTo恢复滚动位置。

标签: onfocus事件 onblur事件 焦点管理 tabIndex 焦点陷阱 无障碍访问 模态框 键盘导航

本文涉及AI创作

内容由AI创作,请仔细甄别

list快速访问

上一篇: JS集成:HTML oninput事件: - 详细教程与实战指南 下一篇: JS集成:HTML onkeydown事 - 详细教程与实战指南

poll相关推荐