做前端表单的人,早晚都会跟 select 打一次硬仗。它看起来是 HTML 里最老实巴交的控件之一——写两个 option,浏览器就给你一个下拉框,好像没什么可讲的。但真到业务里,问题就来了:动态渲染之后 change 事件不触发了、程序里改了 value 页面却没反应、多选下拉想拿全部选中项结果只拿到一个、移动端样式怎么调都跟安卓长得不一样。这些坑我在不同项目里几乎踩了个遍,所以这篇文章不打算复述 MDN 上那段标签说明,而是把 HTML select 的用法和常用事件按“结构 → 属性 → 事件 → 动态数据 → 样式 → 实战 → 排查”这条线完整串一遍。刚入门前端、只写过静态表单的同学可以照着抄;已经写了几年业务、被联动下拉折磨过的朋友,可以直接跳到第 3 章和第 7 章,那两块是我认为最值钱的部分。文中所有代码都是能直接丢进 HTML 文件里跑的原生实现,不依赖任何框架,框架里的封装逻辑本质上也是这些东西。
1. 先搞清楚 select 的结构:三个标签撑起一个控件
1.1 select、option、optgroup 各管什么
原生下拉框其实是由三个标签配合完成的,很多人只认识前两个。<select>是容器,负责定义这个控件的名字、是否多选、显示几行;<option>是每一个可选项,负责承载真正的数据;<optgroup>是可选的中间层,用来给选项分组,它的label属性会渲染成不可点击的组标题。这三者的关系跟“文件夹 → 子文件夹 → 文件”很像,optgroup 就是那个子文件夹,它自己不产生数据,只是把 option 归拢到一起,方便用户在一长串选项里快速定位。
一个结构完整的写法大致是这样:
<select name="city" id="city"> <optgroup label="华东"> <option value="sh">上海</option> <option value="hz">杭州</option> </optgroup> <optgroup label="华南"> <option value="gz">广州</option> <option value="sz">深圳</option> </optgroup> </select>这里有个细节值得说清楚:optgroup本身不能被选中,用户点它没反应,但它的disabled属性可以一次性把组内所有 option 全部禁用,这在“某个分类下的选项当前不可选”的场景里非常省事,不用一个个去加 disabled。
1.2 option 的 value 缺省规则,很多人不知道
<option>的显示文字和它提交的值是两回事。显示的是标签内的文本,提交的是value属性。但如果你偷懒没写 value,浏览器不会报错,它会直接拿 option 的文本内容当作值。这个默认行为在一半情况下是方便的,另一半情况下是灾难:如果选项文本是“上海市(含郊区) ¥12.00”这种带修饰的展示文案,你拿到的 value 就会带着一串你根本不想要的东西。
我的习惯是,只要这个 select 的数据会被提交或者参与逻辑判断,就永远显式写 value,把展示和值彻底分开。多写几个字符,省掉后面一堆字符串截取的破事。
1.3 单选、多选、展开列表:三种完全不同的形态
select 有三种形态,行为差异不小,很多人混着用才出的问题。
| 形态 | 关键属性 | 用户交互 | 取值方式 |
|---|---|---|---|
| 标准单选下拉 | 无 | 点击展开,选一项后自动收起 | select.value |
| 多选列表 | multiple | 按住 Ctrl 或 Shift 多选,不收起 | select.selectedOptions |
| 固定行数列表 | size="4" | 直接显示多行,无下拉箭头 | select.value |
这里最关键的一点:只要加了multiple,select.value就只返回第一个选中项的值。这是新手最容易翻车的地方——页面上明明选了三个,提交上去只有一个。多选场景必须用selectedOptions,这个后面第 4 章会详细讲。
size属性则常被忽略。它默认为 1(也就是下拉形态),一旦设成大于 1 的数字,select 就不再是下拉框,而是一个固定高度的列表框。这种形态在“权限分配”“标签筛选”这类需要用户同时看到多个选项的场景里其实比下拉更好用,因为用户不需要展开就能看到全部内容。
2. 属性细节:selected、disabled、required 里的那些坑
2.1 selected 是初始值,不是当前值
这是我认为 select 里最值得单独讲的一个知识点,理解了它,一半的诡异现象都能解释。
<option selected>这个写法设置的是默认选中项,也就是 HTML 里的 attribute。而用户在页面上点来点去改变的,是 DOM 元素的property,也就是option.selected。这两者不是同一份数据,浏览器在内部是分开记的。当你调用form.reset()的时候,控件会回到 attribute 指定的那个初始状态,而不是回到你刚刚用 JS 设置的状态。
<select id="level"> <option value="a">A</option> <option value="b" selected>B</option> </select>const sel = document.getElementById('level'); sel.value = 'a'; // 此刻界面显示 A,但默认值仍记录为 B sel.options[1].defaultSelected; // true,默认值没变 document.querySelector('form').reset(); // 重置后又回到 B所以我给的建议是:用 JS 修改选中状态时,一律用 property,也就是sel.value = 'a'或者option.selected = true;只有在渲染初始 HTML 模板、需要指定“打开页面时默认选谁”的时候,才用 attribute。两套机制混着用,就会出现“我明明设定过了,怎么一提交就变回去了”这种问题。
2.2 disabled、readonly 和 required 在 select 上的表现
select 上有一个容易误会的点:它有disabled,但没有readonly。很多从 input 转过来的人会顺手写readonly,结果完全没有效果,用户照样能改。如果你想让用户看到内容但不能修改,只有两条路:要么用disabled(但它的值是提交不上去的),要么在提交前用 JS 从一个<input type="hidden">里取值。
disabled的行为要特别注意:被禁用的 select,它的 name/value 不会出现在表单提交的数据里。如果你的表单里有必填项用了 disabled 做“暂不可选”,而后端又期望收到这个字段,就会出现莫名其妙的字段丢失。这种情况下更好的做法是加一个 hidden input 兜底,或者用aria-disabled加事件拦截来模拟禁用。
至于required,它在 select 上生效的判定逻辑是“value 不能为空字符串”。也就是说,如果你的第一个 option 是这样写的:
<option value="">请选择</option>那么用户不选任何东西时,表单校验会拦住他。但如果你把占位项写成<option value="0">请选择</option>,值为"0",非空字符串,校验就通不过拦截——用户什么都没选也能提交。这是一个非常隐蔽的 bug,我在审查代码时见过好几次。占位项的 value 一定要留空。
2.3 用 label 和 for 提升可点击区域
从可用性角度讲,<label for="city">关联到 select 后,用户点标签文字也能把焦点移到下拉框上。这在桌面端是个小优化,在移动端则更明显,因为大屏手机上的小字号标签本来就容易被误触。表单字段一多,这点体验差异会被放大。
无障碍方面还有一点值得注意:select 的title属性会作为辅助提示被朗读,但它不能替代 label。真正规范的做法还是给每个 select 配一个语义化的 label,哪怕视觉上把它隐藏掉(用 clip 而不是 display: none,后者会让屏幕阅读器也读不到)。
3. 常用事件逐个拆:change 只是主角,不是全部
3.1 change 是主战场,但它何时触发有讲究
change是 select 上最重要的一个事件,绝大多数业务逻辑都挂在这里。它的触发时机是“用户通过交互改变了选中项并且控件失去焦点或者操作完成”。对于单选下拉来说,用户一旦选中某一项,下拉收起,change 立刻触发,体验上感觉是即时的。对于多选列表或者size列表,change 会在用户每次 Ctrl + 点击后触发一次,而不是等所有选择做完才触发。
这里必须强调一个高频误解:程序里修改select.value不会触发 change 事件。这不是 bug,是设计如此。change 只在用户主动改变时抛出。所以如果你的代码写了“监听 change 然后把 select.value 改成另一个值,指望再触发一轮 change”,那是不会发生的事,得手动调用处理函数。
const sel = document.getElementById('city'); sel.addEventListener('change', function (e) { console.log('用户选了:', e.target.value); }); // 下面这行不会触发上面的监听器 sel.value = 'hz';把逻辑抽成独立函数,需要时手动调用,是解决这个问题的标准姿势:
function handleCity(value) { // 所有跟城市相关的联动逻辑写这里 } sel.addEventListener('change', (e) => handleCity(e.target.value)); sel.value = 'hz'; handleCity('hz'); // 手动补一次,保证状态同步3.2 input 事件在 select 上的表现
现代浏览器里,select 在用户改变选择时,既会触发input也会触发change,且 input 在 change 之前。这带来一个实际好处:如果你只想统一监听各种表单控件的实时变化,直接监听 input 就够了,不用给 text、select、checkbox 分别写逻辑。这在做“表单整体脏检查”“实时保存草稿”这类功能时非常顺手。
不过它有两个限制。一是程序赋值同样不触发 input;二是在一些老版本浏览器上 select 不支持 input 事件,只能靠 change。所以如果你的项目还要兼容比较旧的运行环境,那就老老实实用 change,别贪这个方便。
3.3 焦点事件与键盘操作
focus和blur在 select 上有个容易踩的坑:在部分浏览器里,用户点击下拉框展开选项、然后选择或关闭的过程,blur 的触发时机和你的直觉不完全一致。如果你在 blur 里做“收起自定义面板”的操作,很可能在用户还没选完的时候面板就被收掉了。我的经验是,凡是跟下拉展开状态相关的逻辑,不要依赖 blur 来判断,改用点击外部区域的 document 级监听更可控。
键盘方面,原生 select 已经内置了相当完整的支持:上下方向键切换选项、键入首字母跳转到对应项、Home/End 跳到首尾、空格在部分浏览器里展开下拉。这些行为不需要你写一行代码。反过来说,如果你用 div 自研了一个下拉组件,就必须把这些键盘行为全部补齐,否则体验会比原生差一大截——这也是我下一章要讲的取舍问题。
3.4 事件委托:动态渲染下拉列表的正确姿势
业务里的下拉框数据大多是异步拉来的,先渲染容器、再填 option。如果你在渲染 option 之后才绑定事件,绑定本身没问题;但如果你想给“每一个 option”绑事件,那就大错特错了——option 元素在很多浏览器里根本接收不到鼠标事件,你在它上面绑的监听器永远不会执行。
正确做法是把监听器挂在 select 上,通过e.target判断。更进一步,如果是页面里有几十个 select 的表单,把监听器挂在表单或者 document 上,用事件委托统一处理,性能和代码整洁度都会好很多:
document.querySelector('#userForm').addEventListener('change', function (e) { const el = e.target; if (el.tagName !== 'SELECT') return; const handlers = { province: onProvinceChange, city: onCityChange, district: onDistrictChange }; const fn = handlers[el.name]; if (typeof fn === 'function') fn(el.value, el); });这种写法的好处是,后续再往表单里加 select,只要在 handlers 里补一条映射就行,不用回头改事件绑定代码。
4. 动态数据下的操作:把 select 当成一个小型数据容器
4.1 options 集合和 selectedIndex 的正确用法
select.options是一个类数组对象,长得像数组但不是数组。这意味着你对它调用forEach会直接报错——HTMLOptionsCollection 继承自 HTMLCollection,而 HTMLCollection 没有 forEach 方法。这个错误我在新手代码里见过太多次,报错信息还是那种让人一头雾水的select.options.forEach is not a function。转换方式很简单:
// 三种都能用 [...select.options].forEach(o => console.log(o.value)); Array.from(select.options).forEach(o => console.log(o.value)); Array.prototype.forEach.call(select.options, o => console.log(o.value));读取当前选中项,除了select.value,还有select.selectedIndex。它是数字索引,适合做“上一项/下一项”这类操作。把selectedIndex设为-1可以清空所有选择,这个技巧在多选列表里很有用,因为多选没有“空值”这个概念,只能靠 -1 来取消全部选中。
4.2 用 new Option 和 DocumentFragment 高效造选项
往 select 里塞 option 有几种写法,效率差别不小。最直观的是拼字符串然后赋给innerHTML,代码短但会丢失原有选中状态,而且抑制了浏览器的部分优化。更规范的是用new Option()构造函数,它接受四个参数:文本、值、默认选中、当前选中。
const sel = document.getElementById('city'); const fragment = document.createDocumentFragment(); cityList.forEach(item => { // new Option(显示文本, 提交值, 是否默认选中, 是否当前选中) fragment.appendChild(new Option(item.name, item.code, false, item.code === current)); }); sel.innerHTML = ''; // 先清空,避免新旧数据混在一起 sel.appendChild(fragment); // 一次性挂载,只触发一次重排用 DocumentFragment 做批量插入,比循环里一次次 appendChild 快很多,因为前者只在最后挂载时触发一次 DOM 重排。选项数量在几十个以内时差异感受不到,但如果是几百项的字典数据,这个优化就很值得。
4.3 重渲染不丢选中值:一个必须处理的顺序问题
联动的场景下会出现这样的情况:省份变了,要重新加载城市列表;如果旧选中的城市在新列表里依然存在,我们希望能保持选中。这里的顺序很关键——必须先插入 option,再把容器加进 DOM,最后设置 value。如果反过来,在容器还没进文档流的时候就去设置 value,部分浏览器会静默失败。
function renderCity(list, keepValue) { const sel = document.getElementById('city'); const fragment = document.createDocumentFragment(); fragment.appendChild(new Option('请选择', '')); let matched = false; list.forEach(item => { const isMatch = item.code === keepValue; if (isMatch) matched = true; fragment.appendChild(new Option(item.name, item.code, false, isMatch)); }); sel.innerHTML = ''; sel.appendChild(fragment); // 没匹配上就回退到占位项,避免残留一个不存在于列表里的值 if (!matched) sel.value = ''; }这个matched判断是我后来加上的,因为不加的话会出现“界面上显示的是 A 城市,但值其实是上一次选过的 B 城市”这种脏数据,排查起来非常费劲。
4.4 清空和重置:reset 和手动清空的区别
表单里的 reset 按钮或者form.reset()会把所有控件恢复到 HTML 里写的初始状态,包括用selectedattribute 指定的默认项。它的好处是不用自己写还原逻辑,坏处是它只认 attribute,不认你后面用 JS 改过的状态。而手动清空就是sel.value = ''或sel.selectedIndex = -1,纯粹是改当前状态,不影响默认值。
两者该用哪个,取决于业务语义。如果是“用户点了取消,回到页面初始状态”,用 reset;如果是“切换了某个筛选条件,需要把这个下拉清空重新选”,用手动清空。我见过有人拿 reset 来做“清空筛选”,结果用户前面填的一整个表单都被清掉了,这种事故挺尴尬的。
5. 样式改造:原生外观能改到什么程度
5.1 appearance: none 之后的配套动作
原生 select 最大的视觉问题是那个系统自带的箭头,各平台长得都不一样,跟设计稿对不上。想换成自己的箭头,核心就一行:
select { -webkit-appearance: none; -moz-appearance: none; appearance: none; /* 自己画一个箭头,用背景图最省事 */ background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23666' stroke-width='2' fill='none'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 36px; }这里有几个必须注意的点。第一,appearance: none之后,箭头区域就成了你自己的责任,如果不加 padding-right,长文本会顶到箭头上。第二,用 SVG 内联成 data URI 比引入图片文件更省一次请求,而且颜色可以直接在 SVG 里改。第三,iOS 上的 Select 字体如果小于 16px,聚焦时可能会触发页面缩放,这个问题困扰过很多人,解决办法就是把字号提到 16px 或者用text-size-adjust控制。
5.2 option 的样式:能改和不能改
必须要明白一件事:展开后的下拉列表是浏览器原生绘制的,CSS 对 option 的控制权非常有限。你只能改一些基础属性,比如 option 的背景色、文字颜色、字号在部分平台有效,但想改行的间距、加图标、做两列布局,那是做不到的。
| 想改的东西 | 原生 select 是否支持 | 说明 |
|---|---|---|
| 下拉框本身的边框圆角 | 支持 | 改 select 的样式即可 |
| 下拉箭头 | 支持 | appearance: none 后自己画 |
| option 背景色 | 部分支持 | Chrome、新版 Safari 有效,各平台差异大 |
| option 的字号字体 | 部分支持 | 移动端基本无效 |
| option 里加图片或图标 | 不支持 | 只能换自研组件 |
| 搜索过滤选项 | 不支持 | 只能用 datalist 或自研 |
所以当你遇到“设计稿要求下拉选项里带国旗图标”“要求能输入关键词搜索选项”这类需求时,就该考虑自研了,别在原生上面死磕。
5.3 什么时候该自研下拉组件
我的判断标准是三条,满足任意一条,就上自研:
- 需要搜索:选项超过 15 个,用户需要输入筛选。
- 需要富内容:选项里要放头像、图标、多行文案、状态标签。
- 需要特殊的交互:比如多选后以标签形式展示在输入框里,或者需要虚拟滚动处理上百个选项。
如果只是“想换箭头颜色”“想改边框”,坚决用原生,别自研。自研下拉组件要处理的细节比想象中多得多:点击外部关闭、键盘导航、焦点管理、滚动定位、无障碍属性、移动端的触摸滚动,随便漏一个就是 bug。而且由于触发的不是原生 change 事件,很多表单库还需要额外适配。用原生能解决的问题,不要为了好看付出这么大的代价。
6. 实战落地:两个具体场景的完整实现
6.1 两级省市联动的完整写法
这是 select 最经典的实战场景,把前面讲的东西全串起来了。核心逻辑是:省份的 change 触发后,加载对应城市列表,重渲染时尽量保留用户之前的城市选择。
const data = { sh: [{ code: 'sh-pd', name: '浦东新区' }, { code: 'sh-hp', name: '黄浦区' }], hz: [{ code: 'hz-xh', name: '西湖区' }, { code: 'hz-yh', name: '余杭区' }] }; const province = document.getElementById('province'); const city = document.getElementById('city'); province.addEventListener('change', function () { const list = data[this.value] || []; const fragment = document.createDocumentFragment(); fragment.appendChild(new Option('请选择', '')); list.forEach(item => fragment.appendChild(new Option(item.name, item.code))); city.innerHTML = ''; city.appendChild(fragment); city.value = ''; // 省份没选时,城市应该禁用,避免用户提交出“有城市无省份”的数据 city.disabled = list.length === 0; }); // 初始化时保持禁用状态 city.disabled = true;这里有两个防御性处理值得强调。一是data[this.value] || [],防止用户切换到一个没有配置数据的省份时报错;二是城市下拉的禁用联动,很多实现会漏掉这一条,导致用户可以在省份为空的条件下选出一个城市,后端收到的数据自相矛盾。加一行 disabled 判断,能省掉不少数据校验的麻烦。
6.2 用 datalist 做轻量可搜索下拉
如果你要的只是“输入关键词筛选”,又不愿意写一整套自研组件,<datalist>是一个被严重低估的方案。
<input list="cityList" id="cityInput" name="city" placeholder="输入城市名"> <datalist id="cityList"> <option value="北京"></option> <option value="上海"></option> <option value="广州"></option> </datalist>用户输入时会实时筛选并弹出建议,选中后填进 input。它的优点是零 JS、移动端体验也不错。缺点很明显:它只是输入建议,不限制用户输入的值必须是列表里的,用户完全可以手打一个不在列表里的城市。所以它适合“辅助输入”的场景,不适合“必须从固定选项里选”的场景。后者还是得用 select。
另外 datalist 的 event 监听挂在 input 上而不是 datalist 上,用户选择建议后触发的是 input 的input事件(部分浏览器还有 change),拿到的就是 input 的当前值。这个特性用的人不多,但很实用。
6.3 多选下拉的取值和标签化输出
多选列表的交互体验一般,但有些后台系统确实是这么用的。核心是把selectedOptions转成数组:
const roleSel = document.getElementById('roles'); roleSel.addEventListener('change', function () { const values = [...this.selectedOptions].map(o => o.value); const labels = [...this.selectedOptions].map(o => o.text); console.log('选中的值:', values); console.log('选中的展示文字:', labels); });这里要注意[...this.selectedOptions]的写法,selectedOptions 同样是 HTMLCollection,不能直接 forEach。用户按住 Ctrl 每次勾选都会触发一次 change,所以如果你在监听器里做了接口请求,那就会一次勾选发一个请求,得做防抖或者改成“确认后再提交”的交互。
7. 常见问题与排查速查
7.1 改了 value 页面却没变?先看这三个原因
第一种可能是值根本不在选项里。select.value = 'xxx',如果没有任何一个 option 的 value 等于 'xxx',浏览器会静默地把选中状态清空,界面上可能显示成空白或者第一项,但selectedIndex变成了 -1。排查方法就是先打印[...select.options].map(o => o.value),确认目标值确实存在于候选列表里。
第二种可能是时机问题,容器还没插入文档流就去设置 value。这种情况下在不同浏览器表现不一致,有的能设上,有的不能。解决办法是把赋值操作放到插入 DOM 之后。
第三种是把 property 和 attribute 搞混了,用setAttribute('value', ...)去改,结果改的是 HTML 属性而非当前值,界面上不会有任何变化。记住:要改当前选中项,用select.value = ...或者option.selected = true。
7.2 change 不触发或者意外重复触发
不触发的头号原因是程序赋值不触发。如果你的初始值是用 JS 设置的,而后续逻辑又依赖 change 跑一次,那初始那次永远不会发生,需要手动调一次处理函数。
重复触发的常见原因是监听器被绑定了多次。比如某个懒加载的模块每次进入页面都执行一遍addEventListener,第二次进入就绑了两次,change 就跑两遍。解决办法有两个:一是用removeEventListener显式解绑;二是改用事件委托,只绑在父容器上一次。另外,如果是{ once: true }的场景,记得别在不需要的地方乱用,否则第二次改动就没反应了。
7.3 表单校验与 required 失效
前面提过,required 在 select 上判定的是“value 是否为空的字符串”。所以只要你的占位项写了非空的 value,校验就会失效。另一种失效情形是 select 被设了 disabled,被禁用的控件不参与校验,也不参与提交。排查这类问题的顺序就是:先看占位项 value 是否为空,再看有没有 disabled,最后看表单有没有加 novalidate 属性关掉了原生校验。
7.4 一张速查表收尾
| 现象 | 大概率原因 | 解决方式 |
|---|---|---|
| 多选只拿到一个值 | 用了 select.value | 改用 selectedOptions |
| options.forEach 报错 | 它是 HTMLCollection | 转数组或用 call |
| 设置 value 界面没变 | 值不在候选项里 | 先核对 options 列表 |
| 程序改值不触发 change | 规范如此 | 手动调用处理函数 |
| reset 后回到奇怪的值 | 默认值来自 selected attribute | 检查模板里的默认项 |
| 表单收不到 select 的值 | 控件被 disabled | 用 hidden 兜底 |
| iOS 上字号忽大忽小 | 聚焦缩放 | 字号提到 16px |
| 选项很多时卡顿 | 逐个 append | 用 DocumentFragment 批量挂载 |
8. 几个容易被忽略的无障碍和兼容细节
无障碍这块,原生 select 相比自研组件最大的优势就是免费。键盘操作、屏幕阅读器朗读、移动端原生选择器,浏览器全帮你做了。所以你在自研时,一定要用role="listbox"、aria-expanded、aria-selected这些属性补齐语义,否则视障用户完全用不了。
另一个细节是<select>的初始渲染值。页面加载时,如果没有 option 带 selected,浏览器会默认选中第一个 option,并不会让 select 处于“空”的状态。这意味着如果你依赖“用户没操作就等于空值”这个假设,就会出错。稳妥做法是永远在第一位放一个value=""的占位项,让“未选择”这个状态显式存在。
还有个兼容性问题值得提一句:在某些旧版本的环境里,动态添加 option 之后,某些表现和现代浏览器不一致,尤其是 selected 的处理。如果你的项目要覆盖比较老的运行环境,动态渲染后建议显式再设一次select.value,多一行代码,换来稳定。
我在实际项目里的体会是,select 这类原生控件最忌讳的就是“想当然”。它不像按钮那样所见即所得,很多行为藏在规范里,只有踩过一次才知道。我现在每接手一个带下拉的表单,第一件事就是把所有 select 的 options 列表和当前值打印出来对一遍,确认没有值不在列表里的脏数据,再做后续逻辑。这个小习惯帮我省下了至少几次线上数据错乱的排查时间。如果你是刚入门的朋友,建议花十分钟把本文第 3 章和第 7 章的代码跑一遍,亲手制造一次“程序赋值不触发 change”和“多选只拿到一个值”,印象会比看文档深得多。