普通视图

发现新文章,点击刷新页面。
昨天以前首页

一天通关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。码字不易,欢迎点赞。

❌
❌