普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月27日首页

一天通关HTML80%核心细节(新手友好版)

2026年4月27日 18:20

一、基础认知

1.HTML 是超文本标记语言(HyperText Markup Language) ,只有标签,没有逻辑、变量、循环,本质是给浏览器看的 "说明书",告诉浏览器页面结构是什么样的。

2.HTML5 不是新语言,HTML5 是 HTML 的第 5 个大版本,我们现在写的所有 HTML 代码,本质都是 HTML5。

3.即使你写错标签、少写闭合、语法错误,浏览器也会尽量渲染页面,不会直接报错。这是 HTML 最友好也最坑人的地方 —— 代码错了但看起来正常,留下隐形 bug。

二、文档结构: 新手都会忽略的细节

image.png

  • <!DOCTYPE html> 是 HTML5 独有的极简声明,老式 HTML 的声明长达 3 行,已经完全淘汰

  • lang="zh-CN" 不要写成 lang="zh",前者更标准

  • meta charset 必须写在 head 的最前面,否则可能出现乱码

  • viewport 标签是移动端适配的基础,没有它手机上页面会缩小

三、最容易踩坑的基础属性

  1. id vs name:90% 新手分不清
属性 作用
id 唯一标识标签
作用 给谁用
前端(label 绑定、JS 查找、CSS 选择器) 全局唯一,一个页面只能有一个相同 id
name 表单提交传数据
后端服务器 可以重复(比如单选框、复选框)

超级大坑

  • 表单提交时,只有带 name 属性的元素才会被发送到后端

  • id 不会被提交到后端,和后端完全无关

  • 写成一样只是为了好记,不是必须一样

2. class vs id

  • id:唯一标识,一个标签只能有一个 id,一个页面只能有一个相同 id
  • class:通用类名,一个标签可以有多个 class,一个页面可以有多个相同 class
  • 优先级:id > class > 标签选择器

3. 路径问题

  • 绝对路径:https://xxx.com/image.jpg
  • 相对路径:./image.jpg(当前目录)、../image.jpg(上级目录)
  • 根路径:/image.jpg(网站根目录)

坑点:本地打开 HTML 文件时,根路径会指向电脑磁盘根目录,而不是项目文件夹,导致资源加载失败。

四、核心标签深度细节

  • <h1> 一个页面只能有一个,SEO 权重最高

  • <p> 段落标签,会自动在上下添加边距

  • <br> 强制换行,单标签,不要写成 </br>

  • <hr> 水平线,单标签

  • <strong> 语义化加粗,表示重要内容;<b> 只是视觉加粗,无语义

  • <em> 语义化斜体,表示强调内容;<i> 只是视觉斜体,无语义

标签 作用 替代的旧写法
<header> 网页头部、板块头部 <div class="header">
<nav> 导航栏 <div class="nav">
<section> 独立内容板块 <div class="section">
<article> 独立文章、帖子、评论 <div class="article">
<aside> 侧边栏 <div class="aside">
<footer> 网页底部、板块底部 <div class="footer">

最佳实践

  • 大块独立内容用 section,纯布局用 div
  • article 是特殊的 section,代表可以独立存在的内容
  • 不要滥用语义化标签,纯排版用 div 就好

3. 列表标签

  • 无序列表 <ul>:只能包含 <li> 子元素
  • 有序列表 <ol>:只能包含 <li> 子元素
  • 自定义列表 <dl>:包含 <dt>(标题)和 <dd>(描述)

4. 超链接 <a>

image.png

  • target="_blank":在新标签页打开

  • rel="noopener noreferrer":安全属性,防止新页面获取原页面信息,必须加

  • href="#":空链接,点击会跳转到页面顶部

  • href="javascript:;":空链接,点击无反应

5. 图片 <img>

image.png

  • alt 属性:图片加载失败时显示的文字,SEO 和无障碍必备

  • 不要用 width 和 height 属性设置图片大小,应该用 CSS

  • 图片是行内块元素,默认底部会有 3px 的空白间隙

五、表单全解:HTML 最核心的交互部分

1. 表单基础结构

image.png

  • action:表单提交的后端 API 地址

  • method:提交方式,常用 GET 和 POST

    • GET:数据拼在 URL 后面,长度有限,不安全
    • POST:数据放在请求体中,长度无限制,安全
  • required:HTML5 自带验证,必填项

2. 常用 input 类型

type 值 作用 HTML5 专属
text 单行文本输入框
password 密码框,输入内容掩码
radio 单选框,相同 name 为一组
checkbox 复选框,相同 name 为一组
submit 提交按钮,点击自动提交表单
button 普通按钮,无默认行为
email 邮箱输入框,自带格式验证
number 数字输入框
date 日期选择器
time 时间选择器
tel 电话号码输入框
search 搜索框

超级重要

  • 单选框必须设置相同的 name 才能实现单选效果
  • 复选框的 name 应该写成数组形式:name="hobby[]",后端才能接收多个值
  • <input type="submit"> 自带提交功能,不需要写任何 JS

3. 其他表单元素

  • <select>:下拉选择框,包含 <option> 子元素
  • <textarea>:多行文本域,不能用 value 属性设置默认值,默认值写在标签中间
  • <button>:按钮标签,比 input 按钮更灵活,可以包含图片、文字等内容

六、HTML5 专属核心特性

1. 多媒体标签

image.png

  • controls:显示播放控件

  • poster:视频封面图

  • 标签中间的文字是浏览器不支持时的备用内容

  1. Canvas 画布

image.png

  • Canvas 是用 JS 绘图的画布,适合画复杂图形、动画、游戏

  • getContext("2d") 是固定写法,开启 2D 绘图模式

  • 3D 绘图用 getContext("webgl")

  1. SVG 矢量图

image.png

  • SVG 是用标签画的矢量图,放大不会失真

  • 适合画图标、简单图形

  • 可以直接嵌入 HTML,也可以作为外部文件引入

4. 本地存储

  • localStorage:永久存储,关闭浏览器不会消失
  • sessionStorage:会话存储,关闭浏览器就消失

七、容易混淆的标签对比

标签 A 标签 B 核心区别
<div> <section> div 纯布局,section 代表独立内容板块
<section> <article> article 是特殊的 section,代表可以独立存在的内容
<b> <strong> b 只是视觉加粗,strong 语义化加粗,表示重要
<i> <em> i 只是视觉斜体,em 语义化斜体,表示强调
<input type="button"> <button> input 按钮只能显示文字,button 按钮可以包含任意内容
<img> <canvas> img 显示图片,canvas 用 JS 绘图
<canvas> <svg> canvas 是位图,放大失真;svg 是矢量图,放大不失真

八、冷门但实用的小标签

  • <details><summary>:折叠面板
  • <mark>:高亮文字
  • <sup><sub>:上标和下标
  • <blockquote>:长引用
  • <code>:行内代码
  • <pre>:预格式化文本,保留空格和换行

九、企业级最佳实践

  • 语义化优先:能用语义化标签就不用 div,提升 SEO 和无障碍

  • 结构和样式分离:不要用行内样式,所有样式都写在 CSS 文件中

  • 标签闭合:所有标签都要正确闭合,单标签不要写闭合标签

  • 属性小写:所有标签和属性都用小写字母

  • alt 属性必写:所有图片都要加 alt 属性

  • label 必绑 input:所有输入框都要有对应的 label 标签

  • 不要用废弃标签<font><center><strike>等已经被 HTML5 淘汰,全部用 CSS 代替

  • 移动端适配:必须加 viewport 标签

  • 安全:外链加 rel="noopener noreferrer"

contenteditable 深度剖析:让网页元素「活」起来

作者 Momo__
2026年4月27日 10:17

contenteditable 深度剖析:让网页元素「活」起来

前端开发者必备技能 | 深入理解 HTML 可编辑属性

📖 基本概念

contenteditable 是什么?

contenteditable 是 HTML5 的一个全局属性(Global Attribute) ,可以让任意 HTML 元素变成可编辑区域。用户可以直接点击元素并修改其内容,无需使用传统的 <input><textarea> 表单元素。

<!-- 最简单用法 -->
<div contenteditable="true">点击这里编辑我</div>

<!-- 等价于 -->
<div contenteditable>我也是可编辑的</div>

属性值说明

说明 示例
true 启用编辑 <div contenteditable="true">
false 禁用编辑 <div contenteditable="false">
plaintext-only 仅纯文本(禁止富文本) <input> 行为类似
空字符串/inherit 继承父元素或默认可编辑 <div contenteditable>
<!-- plaintext-only 场景:评论框只需要纯文本 -->
<article contenteditable="plaintext-only">
  这里只能输入纯文本,富文本格式会被过滤
</article>

浏览器支持情况

现代浏览器全覆盖,包括:

浏览器 支持版本
Chrome 4.0+
Firefox 3.5+
Safari 3.1+
Edge 12+
IE 6.0+(功能有限)

⚠️ 注意:虽然所有现代浏览器都支持,但行为存在差异,需要针对性处理。

⚡ 核心特性

可编辑区域的行为特性

  1. 原生光标(Carets) :自动显示插入符

  2. 文本选择:支持鼠标选中文本

  3. 富文本支持:用户可以输入带格式的文本

  4. 键盘交互:支持快捷键(Ctrl+B 加粗等)

  5. 拖拽操作:支持在元素内拖拽文本

contenteditable vs 表单元素

特性 contenteditable input/textarea
内容格式 HTML 片段(富文本) 纯文本
样式控制 灵活(继承父样式) 受限
语义化
表单提交 需手动处理 自动
XSS 风险
<!-- textarea 的 value 是纯文本 -->
<textarea id="ta">你好</textarea>
<script>
  console.log(document.getElementById('ta').value); // "你好"
</script>

<!-- contenteditable 的 innerHTML 是 HTML -->
<div contenteditable="true">你好</div>
<script>
  // 用户可能输入 <strong>粗体</strong>
  console.log(editor.innerHTML); // "你好" 或 "<strong>粗体</strong>"
</script>

默认的富文本能力

contenteditable="true" 时,浏览器天然支持:

  • 富文本粘贴:从网页复制的带格式内容会保留样式

  • 撤销/重做:Ctrl+Z / Ctrl+Shift+Z

  • 拖拽重新排列:选中文本可拖拽移动位置

  • 浏览器内置格式化:Ctrl+B/I/U 等

🎯 使用场景分析

1. 在线富文本编辑器

最简单的富文本编辑器实现:

<div contenteditable="true" 
     id="editor"
     style="border: 1px solid #ccc; min-height: 200px; padding: 16px;">
</div>

<button onclick="format('bold')">加粗</button>
<button onclick="format('italic')">斜体</button>

<script>
function format(cmd) {
  document.execCommand(cmd, false, null);
}
</script>

2. 可编辑表格

CMS 系统中常见的需求:

<table border="1" style="border-collapse: collapse; width: 100%;">
  <tr>
    <th>商品名称</th>
    <th>价格</th>
    <th>库存</th>
  </tr>
  <tr>
    <td contenteditable="true">iPhone 15</td>
    <td contenteditable="true">5999</td>
    <td contenteditable="true">100</td>
  </tr>
  <tr>
    <td contenteditable="true">MacBook Pro</td>
    <td contenteditable="true">12999</td>
    <td contenteditable="true">50</td>
  </tr>
</table>

<script>
// 保存表格数据
document.querySelectorAll('table').forEach(table => {
  table.addEventListener('blur', (e) => {
    if (e.target.isContentEditable) {
      console.log('Cell updated:', e.target.textContent);
      // 发送到服务器
    }
  }, true);
});
</script>

3. 即时编辑(Click-to-Edit)

用户点击标题直接编辑,类似于 Notion/Figma 的体验:

<h1 class="editable-title" contenteditable="true" 
    data-placeholder="输入标题...">
  点击这里编辑标题
</h1>

<style>
.editable-title {
  outline: none;
  border-bottom: 2px dashed transparent;
  transition: border-color 0.2s;
}
.editable-title:focus {
  border-bottom-color: #4285f4;
}
.editable-title:empty::before {
  content: attr(data-placeholder);
  color: #999;
}
</style>

<script>
document.querySelector('.editable-title').addEventListener('blur', function() {
  saveToServer(this.textContent);
});
</script>

4. 评论/笔记区域

轻量级笔记应用:

<div id="notes" contenteditable="true" 
     style="white-space: pre-wrap;"
     data-placeholder="在这里记录笔记...">
</div>

<script>
// 自动保存到 localStorage
const notes = document.getElementById('notes');
const saved = localStorage.getItem('user-notes');

if (saved) {
  notes.innerHTML = saved;
}

notes.addEventListener('input', debounce(() => {
  localStorage.setItem('user-notes', notes.innerHTML);
}, 500));

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}
</script>

5. 协作编辑场景

配合 WebSocket 或 WebRTC 实现实时协作:

// 基础协作框架示例
class CollaborativeEditor {
  constructor(element) {
    this.element = element;
    this.socket = new WebSocket('ws://your-server');
    
    // 监听本地变化
    this.element.addEventListener('input', () => {
      this.broadcast(this.getContent());
    });
    
    // 接收远程变化
    this.socket.onmessage = (event) => {
      const { content, userId } = JSON.parse(event.data);
      if (userId !== this.userId) {
        this.setContent(content);
      }
    };
  }
  
  getContent() {
    return this.element.innerHTML;
  }
  
  setContent(html) {
    // 保存光标位置
    const selection = window.getSelection();
    const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
    
    this.element.innerHTML = html;
    
    // 恢复光标
    // ... 光标恢复逻辑
  }
  
  broadcast(content) {
    this.socket.send(JSON.stringify({
      content,
      userId: this.userId
    }));
  }
}

💻 代码示例

基础用法

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>ContentEditable 基础示例</title>
  <style>
    .editor {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 20px;
      min-height: 150px;
      font-size: 16px;
      line-height: 1.6;
      outline: none;
      transition: border-color 0.2s, box-shadow 0.2s;
    }
    .editor:focus {
      border-color: #4285f4;
      box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1);
    }
    .editor:empty::before {
      content: attr(data-placeholder);
      color: #aaa;
      pointer-events: none;
    }
  </style>
</head>
<body>
  <div class="editor" 
       contenteditable="true" 
       data-placeholder="输入内容...">初始内容</div>
  
  <p>HTML 内容:<span id="html-output"></span></p>
  <p>纯文本内容:<span id="text-output"></span></p>
  
  <script>
    const editor = document.querySelector('.editor');
    const htmlOutput = document.getElementById('html-output');
    const textOutput = document.getElementById('text-output');
    
    // 获取内容
    function updateOutput() {
      htmlOutput.textContent = editor.innerHTML;
      textOutput.textContent = editor.textContent;
    }
    
    editor.addEventListener('input', updateOutput);
    updateOutput();
    
    // 监听粘贴,保留纯文本
    editor.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      document.execCommand('insertText', false, text);
    });
  </script>
</body>
</html>

获取/设置内容

const editor = document.getElementById('editor');

// 获取内容
const htmlContent = editor.innerHTML;      // 包含 HTML 标签
const textContent = editor.textContent;    // 仅纯文本
const innerText = editor.innerText;        // 仅纯文本(尊重CSS)

// 设置内容
editor.innerHTML = '<p>新内容</p>';

// 追加内容
editor.innerHTML += '<span>追加内容</span>';

// 安全的追加方式
function safeAppend(element, html) {
  const fragment = document.createDocumentFragment();
  const temp = document.createElement('div');
  temp.innerHTML = html;
  while (temp.firstChild) {
    fragment.appendChild(temp.firstChild);
  }
  element.appendChild(fragment);
}

实现简单的富文本功能

class SimpleEditor {
  constructor(element) {
    this.editor = element;
    this.setupToolbar();
    this.setupKeyboardShortcuts();
  }
  
  setupToolbar() {
    document.querySelectorAll('[data-command]').forEach(btn => {
      btn.addEventListener('click', () => {
        const cmd = btn.dataset.command;
        const value = btn.dataset.value || null;
        
        if (cmd === 'createlink') {
          const url = prompt('输入链接地址:');
          if (url) this.exec(cmd, false, url);
        } else if (cmd === 'insertImage') {
          const url = prompt('输入图片地址:');
          if (url) this.exec(cmd, false, url);
        } else {
          this.exec(cmd, false, value);
        }
      });
    });
  }
  
  setupKeyboardShortcuts() {
    this.editor.addEventListener('keydown', (e) => {
      if (e.ctrlKey || e.metaKey) {
        switch (e.key.toLowerCase()) {
          case 'b': e.preventDefault(); this.exec('bold'); break;
          case 'i': e.preventDefault(); this.exec('italic'); break;
          case 'u': e.preventDefault(); this.exec('underline'); break;
          case 's': e.preventDefault(); this.exec('save'); break;
        }
      }
    });
  }
  
  exec(command, showUI, value) {
    if (command === 'save') {
      this.save();
    } else {
      document.execCommand(command, showUI, value);
    }
  }
  
  exec(command, showUI, value) {
    document.execCommand(command, showUI, value);
  }
  
  save() {
    console.log('HTML:', this.editor.innerHTML);
    console.log('Text:', this.editor.textContent);
    // 发送到服务器
    fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify({ content: this.editor.innerHTML }),
      headers: { 'Content-Type': 'application/json' }
    });
  }
  
  getContent() {
    return this.editor.innerHTML;
  }
  
  setContent(html) {
    this.editor.innerHTML = html;
  }
  
  clear() {
    this.editor.innerHTML = '';
  }
}

// 使用
const editor = new SimpleEditor(document.getElementById('editor'));

与 Selection/Range API 配合

现代替代 execCommand 的方式:

// 获取选区
function getSelectionInfo() {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return null;
  
  const range = selection.getRangeAt(0);
  return {
    text: range.toString(),
    startContainer: range.startContainer,
    startOffset: range.startOffset,
    endContainer: range.endContainer,
    endOffset: range.endOffset,
    collapsed: range.collapsed
  };
}

// 包裹选中内容
function wrapSelection(tagName, attributes = {}) {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return;
  
  const range = selection.getRangeAt(0);
  if (range.collapsed) {
    console.warn('没有选中文本');
    return;
  }
  
  const element = document.createElement(tagName);
  Object.entries(attributes).forEach(([key, value]) => {
    element.setAttribute(key, value);
  });
  
  try {
    range.surroundContents(element);
  } catch (e) {
    // 选区跨越多个节点时,需要使用 extractContents
    console.warn('选区跨越多个节点,使用备用方案');
  }
}

// 示例:加粗选中文本
function boldSelection() {
  wrapSelection('strong');
}

// 示例:创建链接
function linkSelection(url) {
  wrapSelection('a', { href: url, target: '_blank' });
}

// 在光标位置插入内容
function insertAtCursor(html) {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return;
  
  const range = selection.getRangeAt(0);
  range.deleteContents();
  
  const fragment = document.createRange().createContextualFragment(html);
  const lastNode = fragment.lastChild;
  
  range.insertNode(fragment);
  
  // 将光标移动到插入内容之后
  if (lastNode) {
    range.setStartAfter(lastNode);
    range.collapse(true);
    selection.removeAllRanges();
    selection.addRange(range);
  }
}

// 示例:在光标位置插入表情
function insertEmoji(emoji) {
  insertAtCursor(`<span class="emoji">${emoji}</span>`);
}

⚠️ 注意事项与坑点

1. XSS 安全问题

这是 contenteditable 最大的坑! 用户输入的内容会被浏览器解析为 HTML。

<!-- 恶意输入示例 -->
<div contenteditable="true">
  <img src onerror="alert('XSS!')">
  <script>document.cookie</script>
  <div onclick="stealData()">点我</div>
</div>

防御方案

// ❌ 危险:直接输出用户输入
div.innerHTML = userInput;

// ✅ 安全方案 1:转义 HTML
function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

// ✅ 安全方案 2:使用 DOMPurify 白名单过滤
import DOMPurify from 'dompurify';

function sanitize(dirty) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br', 'p', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['class']
  });
}

// ✅ 安全方案 3:使用 beforeinput 拦截
editor.addEventListener('beforeinput', (e) => {
  if (e.inputType === 'insertFromPaste') {
    e.preventDefault();
    // 自定义粘贴处理
    const text = e.getTargetRanges()[0].text;
    document.execCommand('insertText', false, text);
  }
});

2. 样式继承问题

contenteditable 会继承父元素的许多样式:

/* ❌ 问题:输入的文字可能继承奇怪的颜色 */
.parent {
  color: red;
  font-family: cursive;
}

/* ✅ 解决方案:明确设置 */
[contenteditable] {
  color: inherit;
  font-family: inherit;
  font-size: inherit;
  /* 关键:允许继承但可被覆盖 */
}

/* ✅ 更好的方案:使用 plaintext-only */
[contenteditable="plaintext-only"] {
  all: unset;  /* 重置所有继承 */
  display: block;
  /* 然后显式设置需要的样式 */
}

3. 焦点管理

// ❌ 问题:程序设置内容会丢失光标
editor.innerHTML = 'new content';  // 光标位置丢失

// ✅ 正确做法:保存和恢复光标
function setContentPreservingCursor(element, html) {
  const selection = window.getSelection();
  const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
  
  // 保存相对位置
  let startOffset = 0, endOffset = 0;
  let startNode, endNode;
  
  if (range) {
    const preRange = document.createRange();
    preRange.selectNodeContents(element);
    preRange.setEnd(range.startContainer, range.startOffset);
    startOffset = preRange.toString().length;
    
    preRange.setEnd(range.endContainer, range.endOffset);
    endOffset = preRange.toString().length;
    
    startNode = range.startContainer;
    endNode = range.endContainer;
  }
  
  element.innerHTML = html;
  
  // 恢复位置(简化版,实际需要更复杂)
  if (range) {
    const newRange = document.createRange();
    // ... 恢复逻辑
  }
}

// ✅ 更简洁的方案:使用 beforeinput 事件

4. 换行行为差异

不同浏览器按 Enter 键产生的 HTML 元素不同:

浏览器 产生的元素
Chrome <div>
Firefox <br>
Safari <p>
// ✅ 解决方案:统一换行行为
editor.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && !e.shiftKey) {
    // 检测浏览器并插入统一的元素
    const browser = detectBrowser();
    
    if (browser === 'chrome') {
      e.preventDefault();
      document.execCommand('insertLineBreak');
    }
  }
});

// ✅ 更好的方案:在初始化时统一配置
document.execCommand('defaultParagraphSeparator', false, 'p');

5. 粘贴内容过滤

editor.addEventListener('paste', (e) => {
  e.preventDefault();
  
  // 获取剪贴板内容
  const clipboardData = e.clipboardData || window.clipboardData;
  
  // 方式 1:只粘贴纯文本(最安全)
  const text = clipboardData.getData('text/plain');
  document.execCommand('insertText', false, text);
  
  // 方式 2:粘贴但过滤危险标签
  const html = clipboardData.getData('text/html');
  if (html) {
    const sanitized = DOMPurify.sanitize(html, {
      ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
      ALLOWED_ATTR: ['href', 'target']
    });
    document.execCommand('insertHTML', false, sanitized);
  }
});

6. MutationObserver 监听变化

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    switch (mutation.type) {
      case 'characterData':
        console.log('文本变化:', mutation.target.textContent);
        break;
      case 'childList':
        console.log('子节点变化');
        mutation.addedNodes.forEach(node => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            console.log('新增元素:', node.tagName);
          }
        });
        break;
    }
  });
});

observer.observe(editor, {
  characterData: true,
  childList: true,
  subtree: true
});

// 清理
// observer.disconnect();

🔧 相关 API

document.execCommand(已废弃但仍在用)

// 常用命令
document.execCommand('bold', false, null);           // 加粗
document.execCommand('italic', false, null);          // 斜体
document.execCommand('underline', false, null);      // 下划线
document.execCommand('strikeThrough', false, null);  // 删除线
document.execCommand('createLink', false, url);      // 创建链接
document.execCommand('insertImage', false, url);      // 插入图片
document.execCommand('formatBlock', false, 'p');     // 段落格式
document.execCommand('insertUnorderedList', false, null); // 无序列表
document.execCommand('insertOrderedList', false, null);   // 有序列表
document.execCommand('undo', false, null);           // 撤销
document.execCommand('redo', false, null);           // 重做
document.execCommand('selectAll', false, null);       // 全选

// 检查命令支持
if (document.queryCommandSupported('bold')) {
  document.execCommand('bold', false, null);
}

⚠️ 警告execCommand 已被 MDN 标记为废弃,但目前在所有浏览器中仍可使用。对于简单场景可以直接使用,对于复杂编辑器建议使用 Selection/Range API。

Selection API

const selection = window.getSelection();

// 获取选中的文本
console.log(selection.toString());

// 获取 Range 对象
if (selection.rangeCount > 0) {
  const range = selection.getRangeAt(0);
  
  // 常用属性
  console.log(range.startContainer);   // 起始容器节点
  console.log(range.startOffset);      // 起始偏移量
  console.log(range.endContainer);      // 结束容器节点
  console.log(range.endOffset);        // 结束偏移量
  console.log(range.collapsed);        // 是否折叠(无选中)
  console.log(range.commonAncestorContainer); // 共同祖先
  
  // 方法
  range.deleteContents();              // 删除选中内容
  range.extractContents();             // 提取选中内容(从 DOM 移除)
  range.cloneContents();               // 克隆选中内容
  range.insertNode(node);              // 插入节点
  range.surroundContents(node);        // 用节点包裹选中内容
}

// 设置选区
const newRange = document.createRange();
newRange.setStart(node, offset);
newRange.setEnd(node, offset);
selection.removeAllRanges();
selection.addRange(newRange);

// 折叠选区
selection.collapseToStart();  // 折叠到起始位置
selection.collapseToEnd();     // 折叠到结束位置

// 全选
selection.selectAllChildren(element);

Range API

const range = document.createRange();

// 设置边界
range.setStart(node, offset);
range.setEnd(node, offset);

// 便捷方法
range.selectNode(node);           // 选中整个节点
range.selectNodeContents(node);    // 选中节点内容
range.setStartBefore(node);       // 开始于节点前
range.setStartAfter(node);        // 开始于节点后
range.setEndBefore(node);         // 结束于节点前
range.setEndAfter(node);          // 结束于节点后

// 比较位置
range.compareBoundaryPoints('START_TO_START', otherRange);
range.compareBoundaryPoints('START_TO_END', otherRange);
range.compareBoundaryPoints('END_TO_END', otherRange);
range.compareBoundaryPoints('END_TO_START', otherRange);

// 操作内容
range.cloneContents();      // 克隆选中内容
range.deleteContents();      // 删除选中内容
range.extractContents();     // 提取内容
range.insertNode(node);     // 插入节点
range.surroundContents(node); // 包裹内容

// 复制粘贴
range.cloneRange();         // 克隆范围
range.detach();            // 释放范围(优化性能)

// 折叠
range.collapse(true);       // 折叠到起点
range.collapse(false);      // 折叠到终点

Input 事件

const editor = document.getElementById('editor');

// input 事件:内容变化后触发
editor.addEventListener('input', () => {
  console.log('内容变化:', editor.innerHTML);
  saveContent();
});

// beforeinput 事件:内容变化前触发,可取消
editor.addEventListener('beforeinput', (e) => {
  // 拦截粘贴为纯文本
  if (e.inputType === 'insertFromPaste') {
    e.preventDefault();
    const text = e.clipboardData.getData('text/plain');
    insertText(text);
  }
  
  // 限制字数
  if (editor.textContent.length >= MAX_LENGTH && 
      e.inputType === 'insertText') {
    e.preventDefault();
  }
});

// compositionstart/end:处理输入法
editor.addEventListener('compositionstart', () => {
  console.log('开始输入中文...');
});
editor.addEventListener('compositionend', () => {
  console.log('中文输入完成');
  handleInput();
});

InputEvent 的 inputType 枚举

// 常用 inputType 值
'insertText'           // 插入文本
'insertLineBreak'      // 插入换行
'insertParagraph'      // 插入段落
'insertOrderedList'    // 插入有序列表
'insertUnorderedList'  // 插入无序列表
'insertFromPaste'      // 从粘贴板粘贴
'formatBold'           // 格式-加粗
'formatItalic'         // 格式-斜体
'formatUnderline'      // 格式-下划线
'deleteContentBackward' // 删除前一个字符
'deleteContentForward'  // 删除后一个字符
'deleteWordBackward'    // 删除前一个单词
'deleteWordForward'     // 删除后一个单词

✅ 最佳实践

安全的内容处理

// 1. 永远不要相信用户输入
function sanitizeUserInput(dirty) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: [
      'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
      'p', 'br', 'hr',
      'ul', 'ol', 'li',
      'blockquote',
      'a', 'img',
      'strong', 'em', 'b', 'i', 'u', 's', 'sub', 'sup',
      'code', 'pre'
    ],
    ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'target'],
    ALLOW_DATA_ATTR: false
  });
}

// 2. 显示时二次转义
function escapeHtml(text) {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

// 3. CSP 配置
// Content-Security-Policy: script-src 'self'; style-src 'self' 'unsafe-inline'

数据绑定方案

class ContentEditor {
  constructor(element, options = {}) {
    this.element = element;
    this.options = {
      placeholder: options.placeholder || '',
      onChange: options.onChange || (() => {}),
      debounceMs: options.debounceMs || 300,
      ...options
    };
    
    this.init();
  }
  
  init() {
    this.element.contentEditable = 'true';
    this.element.dataset.placeholder = this.options.placeholder;
    
    // 初始化内容
    if (this.options.initialValue) {
      this.element.innerHTML = this.options.initialValue;
    }
    
    this.bindEvents();
  }
  
  bindEvents() {
    // 防抖保存
    let timer;
    this.element.addEventListener('input', () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        this.options.onChange(this.getContent());
      }, this.options.debounceMs);
    });
    
    // 失去焦点时立即保存
    this.element.addEventListener('blur', () => {
      clearTimeout(timer);
      this.options.onChange(this.getContent());
    });
    
    // 粘贴过滤
    this.element.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      document.execCommand('insertText', false, text);
    });
  }
  
  getContent() {
    return this.element.innerHTML;
  }
  
  getText() {
    return this.element.textContent;
  }
  
  setContent(html) {
    this.element.innerHTML = html;
  }
  
  clear() {
    this.element.innerHTML = '';
  }
  
  focus() {
    this.element.focus();
  }
}

// 使用
const editor = new ContentEditor(document.getElementById('editor'), {
  initialValue: '<p>Hello World</p>',
  placeholder: '输入内容...',
  onChange: (content) => {
    console.log('保存:', content);
    localStorage.setItem('draft', content);
  },
  debounceMs: 500
});

富文本编辑器推荐

对于生产环境,建议使用成熟的富文本编辑器库:

特点 适用场景
TinyMCE 功能全面、插件丰富、企业级 企业应用、CMS
Quill 轻量、API 简洁、文档友好 轻量级应用
Tiptap Vue/React 友好、扩展性强 现代 SPA
Slate.js 完全可定制、插件化 高度定制需求
ProseMirror Schema 驱动、协作支持 复杂文档、协作
Editor.js 块编辑、JSON 输出 博客、笔记

🚀 现代替代方案

Quill 2.0

import Quill from 'quill';

// 初始化
const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      [{ header: [1, 2, 3, false] }],
      ['bold', 'italic', 'underline', 'strike'],
      [{ list: 'ordered' }, { list: 'bullet' }],
      ['link', 'image', 'blockquote', 'code-block'],
      ['clean']
    ]
  }
});

// 获取/设置内容
quill.on('text-change', () => {
  console.log('HTML:', quill.root.innerHTML);
  console.log('Delta:', quill.getContents());
});

quill.setContents({
  ops: [
    { insert: 'Hello ' },
    { insert: 'World', attributes: { bold: true } },
    { insert: '!\n' }
  ]
});

Tiptap

import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';

const editor = new Editor({
  element: document.querySelector('#editor'),
  extensions: [StarterKit],
  content: '<p>Hello World!</p>',
  onUpdate: ({ editor }) => {
    console.log(editor.getHTML());
    console.log(editor.getJSON());
  }
});

// 命令
editor.chain().focus().toggleBold().run();
editor.chain().focus().setParagraph().run();

小型项目:使用 contenteditable + Selection API

// 极简富文本框(无依赖)
class MinimalEditor {
  constructor(container) {
    this.container = container;
    this.editor = document.createElement('div');
    this.editor.contentEditable = true;
    this.editor.className = 'minimal-editor';
    this.container.appendChild(this.editor);
    
    this.setupStyles();
    this.setupToolbar();
    this.setupPasteHandler();
  }
  
  setupStyles() {
    const style = document.createElement('style');
    style.textContent = `
      .minimal-editor {
        border: 1px solid #ddd;
        padding: 16px;
        min-height: 100px;
        outline: none;
      }
      .minimal-editor:focus { border-color: #4285f4; }
      .minimal-editor-toolbar { margin-bottom: 8px; }
      .minimal-editor button {
        padding: 4px 8px;
        margin-right: 4px;
        cursor: pointer;
      }
    `;
    document.head.appendChild(style);
  }
  
  setupToolbar() {
    const toolbar = document.createElement('div');
    toolbar.className = 'minimal-editor-toolbar';
    toolbar.innerHTML = `
      <button type="button" data-cmd="bold"><b>B</b></button>
      <button type="button" data-cmd="italic"><i>I</i></button>
      <button type="button" data-cmd="underline"><u>U</u></button>
      <button type="button" data-cmd="createLink">🔗</button>
    `;
    
    toolbar.addEventListener('click', (e) => {
      const btn = e.target.closest('button');
      if (!btn) return;
      
      const cmd = btn.dataset.cmd;
      if (cmd === 'createLink') {
        const url = prompt('URL:');
        if (url) this.exec('createLink', url);
      } else {
        this.exec(cmd);
      }
    });
    
    this.container.insertBefore(toolbar, this.editor);
  }
  
  setupPasteHandler() {
    this.editor.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      this.exec('insertText', text);
    });
  }
  
  exec(cmd, value = null) {
    document.execCommand(cmd, false, value);
  }
  
  getContent() {
    return this.editor.innerHTML;
  }
  
  getText() {
    return this.editor.textContent;
  }
}

// 使用
const editor = new MinimalEditor(document.getElementById('container'));

📋 总结

什么时候用 contenteditable?

适合的场景

  • 轻量级富文本编辑(笔记、评论)
  • 即时编辑(click-to-edit)
  • 可编辑表格/列表
  • 需要灵活布局的编辑区域
  • 原型/内部工具

不适合的场景

  • 企业级文档编辑(用 TinyMCE)
  • 需要复杂协作(用 Tiptap/ProseMirror + Yjs)
  • 严格的格式控制(用成熟的编辑器库)
  • 对 XSS 零容忍(除非做好完整防护)

关键要点

  1. 安全第一:永远不要信任用户输入,使用 DOMPurify 等库进行过滤
  2. 关注差异:不同浏览器的行为差异需要针对性处理
  3. 光标管理:修改内容后记得恢复光标位置
  4. 渐进增强:从简单开始,必要时引入编辑器库
  5. 替代方案:生产环境优先考虑成熟的编辑器库

📝 写在最后

contenteditable 是一个「入门简单、深坑不少」的属性。它能快速实现富文本编辑,但要在生产环境稳定使用,需要处理大量的浏览器兼容性和安全问题。

建议:如果是个人项目或内部工具,直接使用 contenteditable 足够;如果是面向用户的产品,强烈建议使用 TinyMCE、Quill 或 Tiptap 等成熟方案。

文档由AI辅助整理

昨天以前首页

构建无障碍组件之Spinbutton Pattern

作者 anOnion
2026年4月26日 16:58

Spinbutton Pattern 详解:构建无障碍数字输入控件

Spinbutton(旋转按钮,也称为 Number InputStepperNumeric SpinnerCounter)是一种输入控件,用于在预定义范围内选择离散数值。本文基于 W3C WAI-ARIA Spinbutton Pattern 规范,详解如何构建无障碍的数字输入组件。

一、Spinbutton 的定义与核心概念

1.1 什么是 Spinbutton

Spinbutton 是一种受限的数字输入控件,具有以下特征:

  • 值被限制在一组或一个范围内的离散值
  • 通常包含三个组件:
    • 文本输入框:显示当前值,通常是唯一可聚焦的组件
    • 增加按钮:用于增加数值
    • 减少按钮:用于减少数值
  • 支持直接编辑按钮调整两种方式
  • 支持小步长大步长调整

1.2 核心术语

术语 说明
Text Field 显示当前值的文本输入框
Increase Button 增加数值的按钮
Decrease Button 减少数值的按钮
Small Step 小步长调整(如按 1 增减)
Large Step 大步长调整(如按 10 增减)
Valid Value 允许范围内的有效值
┌─────────────────────────────────────────────────────────────┐
│                      Spinbutton Container                   │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │                                                     │    │
│  │  ┌─────────────────┐  ┌──────┐  ┌──────┐            │    │
│  │  │                 │  │  ▲   │  │  ▼   │            │    │
│  │  │   Value: 30     │  │  +   │  │  -   │            │    │
│  │  │                 │  │      │  │      │            │    │
│  │  └─────────────────┘  └──────┘  └──────┘            │    │
│  │                                                     │    │
│  │  ┌─────────────────────────────────────────────┐    │    │
│  │  │  role="spinbutton"                          │    │    │
│  │  │  aria-valuenow="30"                         │    │    │
│  │  │  aria-valuemin="0"                          │    │    │
│  │  │  aria-valuemax="100"                        │    │    │
│  │  │  aria-label="Quantity"                      │    │    │
│  │  └─────────────────────────────────────────────┘    │    │
│  │                                                     │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  Keyboard: ↑↓ (±1) | Page Up/Down (±10) | Home/End (Min/Max)│
│                                                             │
└─────────────────────────────────────────────────────────────┘

1.3 典型应用场景

  • 数量选择器:购物车商品数量、酒店预订人数
  • 时间选择器:小时、分钟选择
  • 日期选择器:日、月、年选择
  • 数值调节:音量控制、亮度调节
  • 评分输入:1-5 星评分

二、WAI-ARIA 角色与属性

2.1 基本角色

Spinbutton 使用 role="spinbutton" 标记。

<input
  type="text"
  role="spinbutton"
  aria-label="数量"
  aria-valuenow="1"
  aria-valuemin="0"
  aria-valuemax="10"
  value="1" />

2.2 必需属性

属性 说明 示例值
role="spinbutton" 标记为旋转按钮角色 -
aria-valuenow 当前值 "1"
aria-valuemin 最小值(如果有) "0"
aria-valuemax 最大值(如果有) "10"
aria-labelaria-labelledby 可访问标签 "数量"

2.3 可选属性

属性 说明 示例值
aria-valuetext 用户友好的值描述 "Monday"
aria-invalid 值是否无效 "true" / "false"

2.4 属性详解

aria-valuetext

aria-valuenow 的值不够友好时,使用 aria-valuetext 提供更易理解的描述:

<!-- 星期选择器:数值 1 显示为 "Monday" -->
<input
  type="text"
  role="spinbutton"
  aria-label="星期"
  aria-valuenow="1"
  aria-valuemin="1"
  aria-valuemax="7"
  aria-valuetext="Monday"
  value="Monday" />
aria-invalid

当值超出允许范围时,设置 aria-invalid="true"

<input
  type="text"
  role="spinbutton"
  aria-label="数量"
  aria-valuenow="15"
  aria-valuemin="0"
  aria-valuemax="10"
  aria-invalid="true"
  value="15" />

注意:大多数实现会阻止输入无效值,但在某些场景下可能无法完全阻止。

三、键盘交互规范

3.1 基本键盘交互

按键 功能
↑ Up Arrow 增加数值(小步长)
↓ Down Arrow 减少数值(小步长)
Home 设置值为最小值
End 设置值为最大值
Page Up(可选) 增加数值(大步长)
Page Down(可选) 减少数值(大步长)

3.2 文本编辑键盘交互

如果文本框允许直接编辑,还支持以下标准单行文本编辑键:

  • 可打印字符:在文本框中输入字符
  • 光标移动键:左右箭头、Home、End
  • 选择键:Shift + 方向键
  • 文本操作键:复制、粘贴、删除等

重要提示:确保 JavaScript 不干扰浏览器提供的文本编辑功能。

3.3 焦点行为

  • 操作过程中焦点始终保持在文本框
  • 不需要将焦点移到增减按钮上

四、实现方式

4.1 基础 Spinbutton 结构

<div class="spinbutton-container">
  <label for="quantity">数量</label>
  <div class="spinbutton-wrapper">
    <input
      type="text"
      id="quantity"
      class="spinbutton"
      role="spinbutton"
      aria-label="数量"
      aria-valuenow="1"
      aria-valuemin="0"
      aria-valuemax="10"
      value="1" />
    <div class="spinbutton-buttons">
      <button
        type="button"
        class="spinbutton-up"
        aria-label="增加"
        tabindex="-1"></button>
      <button
        type="button"
        class="spinbutton-down"
        aria-label="减少"
        tabindex="-1"></button>
    </div>
  </div>
</div>

4.2 JavaScript 实现

class Spinbutton {
  constructor(element) {
    this.input = element;
    this.min = parseFloat(this.input.getAttribute('aria-valuemin')) || 0;
    this.max = parseFloat(this.input.getAttribute('aria-valuemax')) || 100;
    this.smallStep = 1;
    this.largeStep = 10;

    this.init();
  }

  init() {
    // 键盘事件
    this.input.addEventListener('keydown', this.handleKeyDown.bind(this));

    // 直接编辑
    this.input.addEventListener('change', this.handleChange.bind(this));
    this.input.addEventListener('blur', this.handleBlur.bind(this));

    // 按钮点击
    const container = this.input.closest('.spinbutton-wrapper');
    const upButton = container.querySelector('.spinbutton-up');
    const downButton = container.querySelector('.spinbutton-down');

    if (upButton) {
      upButton.addEventListener('click', () => this.increment(this.smallStep));
    }
    if (downButton) {
      downButton.addEventListener('click', () =>
        this.decrement(this.smallStep),
      );
    }
  }

  handleKeyDown(e) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;

    switch (e.key) {
      case 'ArrowUp':
        e.preventDefault();
        this.increment(this.smallStep);
        break;
      case 'ArrowDown':
        e.preventDefault();
        this.decrement(this.smallStep);
        break;
      case 'PageUp':
        e.preventDefault();
        this.increment(this.largeStep);
        break;
      case 'PageDown':
        e.preventDefault();
        this.decrement(this.largeStep);
        break;
      case 'Home':
        e.preventDefault();
        this.setValue(this.min);
        break;
      case 'End':
        e.preventDefault();
        this.setValue(this.max);
        break;
    }
  }

  handleChange() {
    const value = parseFloat(this.input.value);
    if (!isNaN(value)) {
      this.setValue(value);
    }
  }

  handleBlur() {
    // 失去焦点时验证并修正值
    const value = parseFloat(this.input.value);
    if (isNaN(value)) {
      this.setValue(this.min);
    } else {
      this.setValue(value);
    }
  }

  increment(step) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;
    this.setValue(currentValue + step);
  }

  decrement(step) {
    const currentValue =
      parseFloat(this.input.getAttribute('aria-valuenow')) || 0;
    this.setValue(currentValue - step);
  }

  setValue(value) {
    // 限制在范围内
    value = Math.max(this.min, Math.min(this.max, value));

    // 更新 ARIA 属性
    this.input.setAttribute('aria-valuenow', value);

    // 更新显示值
    this.input.value = value;

    // 更新有效性状态
    const isValid = value >= this.min && value <= this.max;
    this.input.setAttribute('aria-invalid', !isValid);
  }
}

// 初始化
const spinbuttons = document.querySelectorAll('[role="spinbutton"]');
spinbuttons.forEach((spinbutton) => new Spinbutton(spinbutton));

4.3 带 aria-valuetext 的示例

class WeekdaySpinbutton extends Spinbutton {
  constructor(element) {
    super(element);
    this.weekdays = [
      '',
      'Monday',
      'Tuesday',
      'Wednesday',
      'Thursday',
      'Friday',
      'Saturday',
      'Sunday',
    ];
    this.smallStep = 1;
    this.largeStep = 1; // 星期没有大步长
  }

  setValue(value) {
    // 限制在范围内
    value = Math.max(this.min, Math.min(this.max, value));

    // 更新 ARIA 属性
    this.input.setAttribute('aria-valuenow', value);
    this.input.setAttribute('aria-valuetext', this.weekdays[value]);

    // 显示星期名称
    this.input.value = this.weekdays[value];
  }
}

五、最佳实践

5.1 提供清晰的标签

始终为 Spinbutton 提供描述性的标签:

<!-- 好的示例 -->
<label for="adults">成人数量</label>
<input
  type="text"
  id="adults"
  role="spinbutton"
  aria-label="成人数量"
  ... />

<!-- 不好的示例 -->
<input
  type="text"
  role="spinbutton"
  ... />

5.2 设置合理的范围

根据实际场景设置最小值和最大值:

<!-- 好的示例:酒店预订成人数量 -->
<input
  type="text"
  role="spinbutton"
  aria-label="成人数量"
  aria-valuemin="1"
  aria-valuemax="10"
  ... />

<!-- 不好的示例:没有限制 -->
<input
  type="text"
  role="spinbutton"
  ... />

5.3 使用 aria-valuetext 增强可读性

当数值不够直观时,使用 aria-valuetext

<!-- 好的示例:月份选择 -->
<input
  type="text"
  role="spinbutton"
  aria-label="月份"
  aria-valuenow="1"
  aria-valuemin="1"
  aria-valuemax="12"
  aria-valuetext="January"
  value="January" />

5.4 验证用户输入

阻止无效字符输入,或在失去焦点时修正值:

// 阻止非数字输入
spinbutton.addEventListener('keypress', (e) => {
  if (!/\d/.test(e.key)) {
    e.preventDefault();
  }
});

// 失去焦点时验证
spinbutton.addEventListener('blur', () => {
  const value = parseInt(spinbutton.value);
  if (isNaN(value) || value < min || value > max) {
    // 修正为有效值
    setValue(Math.max(min, Math.min(max, value || min)));
  }
});

5.5 考虑移动端体验

在移动设备上,考虑使用数字键盘:

<input
  type="number"
  inputmode="numeric"
  pattern="[0-9]*"
  role="spinbutton"
  ... />

5.6 提供视觉反馈

  • 无效值时显示错误状态
  • 焦点状态清晰可见
  • 按钮悬停效果
[role='spinbutton'][aria-invalid='true'] {
  border-color: #ef4444;
  background-color: #fef2f2;
}

[role='spinbutton']:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

六、常见错误

6.1 忘记设置 aria-valuenow

<!-- 错误 -->
<input
  type="text"
  role="spinbutton"
  value="5" />

<!-- 正确 -->
<input
  type="text"
  role="spinbutton"
  aria-valuenow="5"
  value="5" />

6.2 按钮可聚焦

<!-- 错误:按钮不应该可聚焦 -->
<button class="spinbutton-up"></button>

<!-- 正确:按钮设置 tabindex="-1" -->
<button
  class="spinbutton-up"
  tabindex="-1"></button>

6.3 忽略键盘交互

只实现按钮点击,不实现键盘支持(方向键、Home/End)。

6.4 不验证输入值

允许用户输入超出范围的值或无效字符。

七、Spinbutton vs 其他输入控件

7.1 Spinbutton vs Slider

特性 Spinbutton Slider
输入方式 键盘输入 + 按钮 拖拽滑块
适用场景 精确数值、离散值 连续范围、粗略选择
精度 中等
典型用例 数量、时间 音量、亮度

7.2 Spinbutton vs 普通文本输入

特性 Spinbutton 普通文本输入
值限制 有最小/最大值 无限制
步长调整 支持 不支持
辅助技术 读出当前值和范围 只读出文本
典型用例 年龄、评分 姓名、地址

八、总结

构建无障碍的 Spinbutton 组件需要关注:

  1. 正确的角色:使用 role="spinbutton"
  2. 必需的属性aria-valuenowaria-valueminaria-valuemaxaria-label
  3. 可选属性aria-valuetextaria-invalid
  4. 完整的键盘支持:方向键调整、Page Up/Down 大步长、Home/End 快捷键
  5. 直接编辑支持:允许用户直接输入值
  6. 输入验证:阻止无效字符,修正超出范围的值
  7. 清晰的标签:帮助用户理解控件用途
  8. 按钮不可聚焦:只有文本框可聚焦

遵循 W3C Spinbutton Pattern 规范,我们能够创建既实用又无障碍的数字输入控件,为所有用户提供便捷的数值选择体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

写 HTML 就能做视频?HeyGen 开源的这个工具有点意思

作者 奇舞精选
2026年4月24日 20:34

HeyGen 开源了一个叫 HyperFrames 的框架,让你用 HTML、CSS 和 GSAP 来做视频。不是概念演示,是真能用的那种。


为什么要用代码做视频

用过 After Effects 或 Premiere 的人都知道,手动调关键帧是个力气活。做一个 10 秒的片头可能要调半小时,改个颜色又得重来一遍。项目文件是二进制格式,Git 根本管不了,团队协作基本靠 U 盘传文件。

HyperFrames 的思路很简单:既然前端开发都是写代码,视频为什么不能也写代码?HTML 定义元素,CSS 控制样式,GSAP 做动画,所有东西都是文本文件。Git 能管,改起来方便,批量生成写个脚本就行。配合 AI 的话,直接说"把标题改成从左边滑入",改完立刻能看效果。

这套东西适合谁用?如果你是做电影级特效,老老实实用 AE。但如果你是前端开发者,经常要做数据可视化、产品介绍视频、动态字幕这类东西,HyperFrames 能省不少事。

实现原理

HyperFrames 是一个四层架构,从上到下:

CLI (hyperframes render)
    ↓
Producer (@hyperframes/producer)   负责完整渲染流水线
    ↓
Engine (@hyperframes/engine)       负责帧捕获
    ↓
Core (@hyperframes/core)           提供运行时、类型、FrameAdapter

用户写 HTML,CLI 调 Producer,Producer 驱动 Engine 逐帧捕获,Core 负责页面内的时间轴控制。


核心机制:Seek-and-Capture 循环

HyperFrames 的做法: 不播放,只 seek。每一帧都是独立的静态快照:

for (let frame = 0; frame <= totalFrames; frame++) {
  const time = Math.floor(frame) / fps;  // 整数除法,无浮点误差
  await adapter.seekFrame(frame);         // 把动画拨到这一时刻
  // 捕获当前像素
}

时间计算用整数帧号除以 fps,不依赖任何系统时钟。


帧捕获:HeadlessExperimental.beginFrame

引擎启动的是 chrome-headless-shell(专为 CDP 控制优化的最小 Chrome 二进制),通过 Chrome DevTools Protocol 调用 HeadlessExperimental.beginFrame

这个 API 的作用是:显式命令合成器渲染一帧,并把像素 buffer 直接返回给调用方。效果是:

  • 没有"等渲染完成"的时序问题
  • 像素直接从 GPU 合成器取,不经过截图的 IPC 拷贝流程
  • 每帧是原子操作,不存在半渲染状态

FrameAdapter 协议:动画运行时的接入层

HyperFrames 不锁定任何动画库。它定义了一个 FrameAdapter 接口,任何能"按帧 seek"的东西都能接入:

type FrameAdapter = {
  id: string;
  init?: (ctx: FrameAdapterContext) => Promise<void> | void;
  getDurationFrames: () => number;       // 视频总帧数
  seekFrame: (frame: number) => void;    // 把动画拨到第 N 帧
  destroy?: () => void;
};

GSAP 的 adapter 实现大概是:

seekFrame(frame) {
  const time = frame / fps;
  gsap.globalTimeline.pause();
  gsap.globalTimeline.seek(time);   // 直接拨时间轴
}

seekFrame 必须是幂等的(同一帧调两次结果相同),且必须支持随机 seek(可以先 seek 第 90 帧再 seek 第 10 帧),不能有顺序依赖。


window.__hf 协议:引擎和页面的通信桥

引擎(Node.js 进程)和页面(浏览器内)之间通过 window.__hf 对象通信:

interface HfProtocol {
  duration: number;          // 视频总时长(秒)
  seek(time: number): void;  // 引擎调这个来驱动帧 seek
  media?: HfMediaElement[];  // 音视频元素声明(给引擎做音频抽取用)
}

页面加载完成后,Core 注入的运行时把自己挂在 window.__hf 上。引擎每帧调 page.evaluate(() => window.__hf.seek(t)),页面内的 FrameAdapter 响应,GSAP 时间轴被拨到对应位置,然后引擎立刻调 beginFrame 捕获。

任何实现了这个协议的页面都能被引擎渲染,不局限于 HyperFrames 格式的 HTML。


音频处理:单独抽取,最后混合

浏览器渲染是纯视觉的,音频不能从帧里捕获。Producer 的做法是把音频流程完全分离:

  1. 解析 HTML 里的 <audio><video> 元素,读取 data-startdata-durationdata-volume 等属性
  2. 用 FFmpeg 从源文件里单独提取音轨,按时间轴剪切、调音量
  3. 所有音轨混合成一个主音轨
  4. 视频帧编码完成后,再用 FFmpeg 把视频和音轨 mux 到一起

并行渲染

单个 Engine session 是串行的(一帧一帧 seek),但 Producer 会开多个 session 并行:

calculateOptimalWorkers(totalFrames)  // 根据 CPU 核数算出最优 worker 数
distributeFrames(totalFrames, workers) // 把帧分段,每个 worker 负责一段
executeParallelCapture(tasks)          // 并行跑,各 worker 独立开 Chrome 实例

每个 worker 是完全独立的 capture session,有自己的 Chrome 进程和页面实例,不共享状态。最后按帧序号合并,送给 FFmpeg 编码。


确定性保证

同一份 HTML,任意时间在任意机器上渲染,输出的 MP4 应该二进制相同(Docker 模式下严格成立)。这靠几件事保证:

  • 时间用 Math.floor(frame) / fps 计算,不用 Date.now()
  • seekFrame 幂等且无顺序依赖
  • 所有资源在渲染前必须加载完(有 __renderReady readiness gate)
  • 禁止 Math.random()(无 seed)
  • Chrome 版本固定(Docker 模式下完全锁定)

本地渲染可能因系统字体和 Chrome 小版本差异有微小像素差异,Docker 模式消除这个问题。


完整流程图

npx hyperframes render
        │
        ▼
CLI → Producer
        │
        ├─► 解析 HTML,提取音视频元素
        │
        ├─► 启动 File Server(HTTP 本地服务,给 Chrome 加载文件用)
        │
        ├─► 启动 N 个 worker(每个 worker 一个 Chrome 实例)
        │        │
        │        ▼
        │   initializeSession(html)
        │        │
        │        ├─► 注入 Core 运行时(挂 window.__hf)
        │        │
        │        └─► for each frame:
        │               window.__hf.seek(t)   ← GSAP timeline.seek(t)
        │               HeadlessExperimental.beginFrame
        │               → pixel buffer
        │
        ├─► pixel buffer → FFmpeg → video.mp4(无音频)
        │
        ├─► 音频抽取 → 混合 → audio.wav
        │
        └─► FFmpeg mux(video.mp4 + audio.wav) → output.mp4

安装

npx hyperframes init my-video

项目结构

my-video/
├── index.html          # 主时间轴文件
├── meta.json           # 项目元数据(id, name)
├── hyperframes.json    # 路径配置
├── narration.wav       # 音频文件(可选)
├── transcript.json     # 转录文件(可选)
├── compositions/       # 子组件目录
│   └── intro.html
└── assets/             # 静态资源
    ├── images/
    └── fonts/

核心概念

1. 时间轴声明

用 data 属性定义时间:

<div 
  class="clip"
  data-start="0" 
  data-duration="5" 
  data-track-index="1"
>
  <h1>Hello World</h1>
</div>

必须的三个属性:

  • data-start: 开始时间(秒)
  • data-duration: 持续时长(秒)
  • data-track-index: 图层索引(类似 AE)

注意:有时间属性的元素必须加 class="clip",框架用它控制显示。

2. GSAP 动画

// 创建并注册时间轴
var tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;

// 添加动画
tl.from(".title", {
  y: 100,        // 从下方 100px 进入
  opacity: 0,    // 从透明到不透明
  duration: 1.0,
  ease: "power3.out"
}, 0.2);  // 在 0.2 秒处开始

常用缓动函数:

  • power2.out - 快入慢出
  • power3.out - 更强烈的快入慢出
  • back.out(1.7) - 回弹效果
  • elastic.out - 弹性效果

3. 字幕同步

var GROUPS = [
  { id: "cg-0", start: 0.5, end: 2.0 },
  { id: "cg-1", start: 2.2, end: 3.8 }
];

GROUPS.forEach(function(group) {
  var el = document.getElementById(group.id);
  
  // 入场
  tl.fromTo(el, 
    { opacity: 0, visibility: "visible" },
    { opacity: 1, duration: 0.3 },
    group.start
  );
  
  // 退场
  tl.to(el, { opacity: 0 }, group.end - 0.15);
  tl.set(el, { visibility: "hidden" }, group.end);
});

效果展示

我做了个智能手表的产品介绍视频,14 秒,三个场景。

智能手表产品介绍

三个场景的安排:

  • 场景 1(0-4s):产品名 + 价格,用了 back.out 回弹效果
  • 场景 2(4-10s):三张功能卡片,stagger 交错出现
  • 场景 3(10-14s):CTA 按钮,elastic.out 弹性动画

下面拆开看看每个场景怎么写的。

场景 1:产品展示

// 产品名称从下方弹入
tl.from(" .product-name", {
  y: 100, opacity: 0, duration: 0.8, ease: "power3.out"
}, 0.3);

// 价格放大淡入(带回弹)
tl.from(" .price", {
  scale: 0, opacity: 0, duration: 0.6, ease: "back.out(1.7)"
}, 1.2);

场景 2:功能卡片

// 三张卡片交错出现
tl.from(" .feature-card", {
  y: 60, 
  opacity: 0, 
  duration: 0.5,
  stagger: 0.2  // 关键:每张间隔0.2秒
}, 4.8);

CSS 毛玻璃效果:

.feature-card {
  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(10px);
  border: 2px solid rgba(255, 255, 255, 0.2);
}

场景 3:CTA按钮

// 按钮弹入
tl.from(" .cta-button", {
  scale: 0, opacity: 0, duration: 0.6, ease: "elastic.out(1, 0.5)"
}, 11.0);

// 脉冲动画(吸引点击)
tl.to(" .cta-button", {
  scale: 1.1, duration: 0.3, repeat: 3, yoyo: true
}, 11.8);

总结

HyperFrames 的核心思路就是把视频当代码管。对前端开发者来说,这套东西上手很快,HTML、CSS、GSAP 都是熟悉的技术栈。

不过也别指望它能做电影级特效。毕竟是基于浏览器渲染的,复杂的 3D 动画、粒子效果这些做不了。但对于产品介绍、数据可视化、字幕动画这类需求,够用了。

构建无障碍组件之Window Splitter Pattern

作者 anOnion
2026年4月19日 22:02

Window Splitter Pattern 详解:构建可拖拽面板分割器

Window Splitter(窗口分割器,也称为 Resizable SplitterPane ResizerSplit PanelDivider)是一种可移动的分隔组件,用于调整两个相邻面板(pane)的相对大小。本文基于 W3C WAI-ARIA Window Splitter Pattern 规范,详解如何构建无障碍的窗口分割器组件。

一、Window Splitter 的定义与核心概念

1.1 什么是 Window Splitter

Window Splitter 是一种可移动的分隔条,位于两个面板之间,允许用户调整面板的相对大小。它具有以下特征:

  • 位于两个面板之间,作为可交互的分隔线
  • 支持拖拽调整面板大小
  • 可以是**可变(variable)固定(fixed)**类型
    • 可变分割器:可以在允许范围内调整到任意位置
    • 固定分割器:在两个固定位置之间切换
  • 具有表示**主面板(primary pane)**大小的数值

1.2 核心术语

术语 说明
Primary Pane 主面板,分割器的值表示该面板的大小
Secondary Pane 次面板,大小随主面板变化而调整
Variable Splitter 可变分割器,可在范围内任意调整
Fixed Splitter 固定分割器,只能在两个位置间切换
Value 分割器当前值,表示主面板的大小(通常为 0-100)
┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│  ┌──────────────────┬──────────────────────────────────────┐    │
│  │                  │                                      │    │
│  │   Primary Pane   │          Secondary Pane              │    │
│  │                  │                                      │    │
│  │  ┌────────────┐  │  ┌────────────────────────────────┐  │    │
│  │  │            │  │  │                                │  │    │
│  │  │  Content   │  │  │         Content                │  │    │
│  │  │            │  │  │                                │  │    │
│  │  └────────────┘  │  └────────────────────────────────┘  │    │
│  │                  │                                      │    │
│  └──────────────────┼──────────────────────────────────────┘    │
│                     │                                           │
│              ┌──────┴──────┐                                    │
│              │  Splitter   │  <-- draggable separator           │
│              │  (separator)│      role="separator"              │
│              └─────────────┘      aria-valuenow                 │
│                                                                 │
│  Value = 30 (Primary: 30%, Secondary: 70%)                      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

注意:"主面板"仅表示该面板的大小由分割器控制,不表示其内容更重要。

1.3 典型应用场景

  • 代码编辑器:左侧文件树,右侧代码编辑区
  • 阅读应用:左侧目录,右侧正文内容
  • 邮件客户端:左侧邮件列表,右侧邮件详情
  • 设计工具:左侧工具栏,右侧画布

二、WAI-ARIA 角色与属性

2.1 基本角色

Window Splitter 使用 role="separator" 标记。从 ARIA 1.1 开始,当 separator 元素可聚焦时,它被视为一个控件(widget)

<div
  role="separator"
  aria-label="目录"
  aria-valuenow="30"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-controls="primary-pane"
  tabindex="0">
</div>

2.2 必需属性

属性 说明 示例值
role="separator" 标记为分隔符角色 -
aria-valuenow 当前值,表示主面板大小 "30"
aria-valuemin 最小值,主面板最小时的位置 "0"
aria-valuemax 最大值,主面板最大时的位置 "100"
aria-controls 指向主面板元素 "primary-pane"
aria-labelaria-labelledby 可访问标签,应与主面板名称匹配 "目录"

2.3 属性详解

aria-valuenow

表示分割器的当前位置,通常映射为主面板的百分比大小:

  • 0:主面板完全折叠(最小)
  • 100:主面板完全展开(最大)
  • 30:主面板占 30%,次面板占 70%
aria-controls

指向主面板元素,让辅助技术知道分割器控制哪个面板:

<div id="primary-pane" role="region" aria-label="目录">
  <!-- 主面板内容 -->
</div>

<div
  role="separator"
  aria-controls="primary-pane"
  ...>
</div>
aria-label

标签应与主面板名称匹配,帮助用户理解分割器的作用:

<!-- 好的示例 -->
<div role="region" aria-label="目录" id="toc-pane">...</div>
<div role="separator" aria-label="目录" aria-controls="toc-pane">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="分割器">...</div>

三、键盘交互规范

3.1 基本键盘交互

按键 功能
← Left Arrow 垂直分割器向左移动
→ Right Arrow 垂直分割器向右移动
↑ Up Arrow 水平分割器向上移动
↓ Down Arrow 水平分割器向下移动
Enter 切换主面板的展开/折叠状态
Home(可选) 将分割器移到最小位置(可能完全折叠主面板)
End(可选) 将分割器移到最大位置(可能完全展开主面板)
F6(可选) 在窗口面板之间循环切换焦点

3.2 Enter 键行为详解

Enter 键用于切换主面板的折叠状态

  • 如果主面板未折叠:折叠主面板(分割器移到最小值)
  • 如果主面板已折叠:恢复分割器到之前的位置
function handleEnter(splitter) {
  const currentValue = parseInt(splitter.getAttribute('aria-valuenow'));
  const minValue = parseInt(splitter.getAttribute('aria-valuemin'));
  
  if (currentValue > minValue) {
    // 主面板未折叠,保存当前位置并折叠
    splitter.dataset.previousValue = currentValue;
    setSplitterValue(splitter, minValue);
  } else {
    // 主面板已折叠,恢复到之前的位置
    const previousValue = parseInt(splitter.dataset.previousValue || '50');
    setSplitterValue(splitter, previousValue);
  }
}

3.3 固定分割器的键盘交互

固定分割器只支持 Enter 键,不支持方向键:

  • 在两个固定位置之间切换
  • 例如:折叠/展开侧边栏

四、鼠标交互规范

4.1 拖拽行为

  • 鼠标按下:开始拖拽,记录起始位置
  • 鼠标移动:实时更新分割器位置和面板大小
  • 鼠标释放:结束拖拽,保存最终位置

4.2 视觉反馈

  • 悬停状态:鼠标悬停时显示可拖拽的视觉提示(如改变光标为 col-resizerow-resize
  • 拖拽状态:拖拽过程中显示视觉反馈(如半透明遮罩)
  • 焦点状态:键盘聚焦时显示清晰的焦点指示器
[role="separator"] {
  cursor: col-resize; /* 垂直分割器 */
}

[role="separator"][aria-orientation="horizontal"] {
  cursor: row-resize; /* 水平分割器 */
}

[role="separator"]:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

五、实现方式

5.1 基础 Window Splitter 结构

<!-- 窗口容器 -->
<div class="window-container">
  <!-- 主面板 -->
  <div
    id="primary-pane"
    class="primary-pane"
    role="region"
    aria-label="目录">
    <!-- 主面板内容 -->
    <nav>
      <h2>目录</h2>
      <ul>
        <li><a href="#ch1">第一章</a></li>
        <li><a href="#ch2">第二章</a></li>
      </ul>
    </nav>
  </div>

  <!-- 分割器 -->
  <div
    role="separator"
    class="splitter"
    aria-label="目录"
    aria-valuenow="30"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-controls="primary-pane"
    tabindex="0">
  </div>

  <!-- 次面板 -->
  <div
    class="secondary-pane"
    role="region"
    aria-label="内容">
    <!-- 次面板内容 -->
    <article>
      <h1>文章标题</h1>
      <p>文章内容...</p>
    </article>
  </div>
</div>

5.2 CSS 样式

.window-container {
  display: flex;
  height: 100vh;
}

.primary-pane {
  width: 30%; /* 初始宽度对应 aria-valuenow="30" */
  min-width: 0;
  overflow: auto;
}

.splitter {
  width: 4px;
  background-color: #e5e7eb;
  cursor: col-resize;
  transition: background-color 0.2s;
}

.splitter:hover,
.splitter:focus {
  background-color: #3b82f6;
}

.splitter:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

.secondary-pane {
  flex: 1;
  overflow: auto;
}

5.3 JavaScript 实现

class WindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    this.container = this.splitter.parentElement;
    
    this.isDragging = false;
    this.startX = 0;
    this.startWidth = 0;
    
    this.init();
  }

  init() {
    // 鼠标事件
    this.splitter.addEventListener('mousedown', this.handleMouseDown.bind(this));
    document.addEventListener('mousemove', this.handleMouseMove.bind(this));
    document.addEventListener('mouseup', this.handleMouseUp.bind(this));
    
    // 键盘事件
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleMouseDown(e) {
    this.isDragging = true;
    this.startX = e.clientX;
    this.startWidth = this.primaryPane.offsetWidth;
    this.container.style.userSelect = 'none';
  }

  handleMouseMove(e) {
    if (!this.isDragging) return;
    
    const delta = e.clientX - this.startX;
    const newWidth = this.startWidth + delta;
    const containerWidth = this.container.offsetWidth;
    const percentage = Math.round((newWidth / containerWidth) * 100);
    
    this.setValue(percentage);
  }

  handleMouseUp() {
    this.isDragging = false;
    this.container.style.userSelect = '';
  }

  handleKeyDown(e) {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    const step = 5; // 每次移动 5%

    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        this.setValue(Math.max(minValue, currentValue - step));
        break;
      case 'ArrowRight':
        e.preventDefault();
        this.setValue(Math.min(maxValue, currentValue + step));
        break;
      case 'Home':
        e.preventDefault();
        this.setValue(minValue);
        break;
      case 'End':
        e.preventDefault();
        this.setValue(maxValue);
        break;
      case 'Enter':
        e.preventDefault();
        this.toggleCollapse();
        break;
    }
  }

  setValue(value) {
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    
    // 限制在范围内
    value = Math.max(minValue, Math.min(maxValue, value));
    
    // 更新 ARIA 属性
    this.splitter.setAttribute('aria-valuenow', value);
    
    // 更新视觉
    this.primaryPane.style.width = value + '%';
  }

  toggleCollapse() {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    
    if (currentValue > minValue) {
      // 保存当前值并折叠
      this.splitter.dataset.previousValue = currentValue;
      this.setValue(minValue);
    } else {
      // 恢复之前的位置
      const previousValue = parseInt(this.splitter.dataset.previousValue || '30');
      this.setValue(previousValue);
    }
  }
}

// 初始化
const splitter = document.querySelector('[role="separator"]');
new WindowSplitter(splitter);

5.4 固定分割器实现

固定分割器只支持 Enter 键切换:

class FixedWindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    
    this.positions = [0, 30]; // 两个固定位置:折叠、展开
    this.currentIndex = 1; // 默认展开
    
    this.init();
  }

  init() {
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleKeyDown(e) {
    if (e.key === 'Enter') {
      e.preventDefault();
      this.togglePosition();
    }
  }

  togglePosition() {
    this.currentIndex = (this.currentIndex + 1) % this.positions.length;
    const value = this.positions[this.currentIndex];
    
    this.splitter.setAttribute('aria-valuenow', value);
    this.primaryPane.style.width = value + '%';
  }
}

六、最佳实践

6.1 提供清晰的标签

分割器的标签应与主面板名称匹配:

<!-- 好的示例 -->
<div role="region" aria-label="文件树" id="file-tree">...</div>
<div role="separator" aria-label="文件树" aria-controls="file-tree">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="拖拽调整">...</div>

6.2 确保键盘可访问

  • 分割器必须可聚焦(tabindex="0"
  • 支持方向键调整位置
  • 支持 Enter 键折叠/展开

6.3 提供视觉反馈

  • 悬停时改变光标样式
  • 焦点状态清晰可见
  • 拖拽过程中实时更新面板大小

6.4 限制调整范围

设置合理的 aria-valueminaria-valuemax,防止面板过小或过大:

<!-- 主面板最小 15%,最大 50% -->
<div
  role="separator"
  aria-valuemin="15"
  aria-valuemax="50"
  ...>
</div>

6.5 保存用户偏好

记住用户调整后的面板大小,下次访问时恢复:

// 保存
localStorage.setItem('splitter-value', splitter.getAttribute('aria-valuenow'));

// 恢复
const savedValue = localStorage.getItem('splitter-value');
if (savedValue) {
  splitter.setAttribute('aria-valuenow', savedValue);
  primaryPane.style.width = savedValue + '%';
}

6.6 响应式设计考虑

在小屏幕上,考虑禁用分割器或提供替代方案:

@media (max-width: 768px) {
  [role="separator"] {
    display: none; /* 小屏幕隐藏分割器 */
  }
  
  .primary-pane {
    width: 100% !important; /* 全宽显示 */
  }
}

七、常见错误

7.1 忘记设置 aria-controls

<!-- 错误 -->
<div role="separator" aria-label="目录"></div>

<!-- 正确 -->
<div role="separator" aria-label="目录" aria-controls="primary-pane"></div>

7.2 标签与主面板不匹配

<!-- 错误 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="调整大小">...</div>

<!-- 正确 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="目录">...</div>

7.3 忽略键盘交互

只实现鼠标拖拽,不实现键盘支持,导致键盘用户无法调整面板大小。

八、总结

构建无障碍的 Window Splitter 组件需要关注:

  1. 正确的角色:使用 role="separator"
  2. 必需的属性aria-valuenow aria-valuemin aria-valuemax aria-controls aria-label
  3. 完整的键盘支持:方向键调整、Enter 键折叠、Home/End 快捷键
  4. 鼠标拖拽支持:mousedown/mousemove/mouseup 事件
  5. 清晰的标签:标签与主面板名称匹配
  6. 视觉反馈:悬停、焦点、拖拽状态的视觉提示

遵循 W3C Window Splitter Pattern 规范,我们能够创建既实用又无障碍的面板分割器,提升所有用户的操作体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

❌
❌