需求评审的时候,产品在图上圈了一下右上角的账号切换器,说:「这里要能看出每个人属于哪个套餐,最好带个小头像。」
我看了一眼现在的实现——一个 <select>,选项文本是「张三(专业版)」。挺符合 HTML 的用法,但确实不好看,也没法放头像。
然后产品补了一句:「不用太复杂,就两行就行。」
这句话让我在接下来一周里改了三次方案。
原生 select 的硬约束
先说清楚为什么这事不好办。<option> 元素的内容模型非常特殊——它只能包含文本,任何标签都会被浏览器当作文本内容的一部分处理(或者干脆被丢弃)。
<select>
<option value="1">
<img src="/avatar/zhang.png" alt="">
<span>张三</span>
<small>专业版</small>
</option>
</select>
写是这样写没错,但渲染出来就是一串纯文字,或者干脆什么都没有。这是 HTML 规范层面的限制,不是你写错了。
所以过去十年,业内解决这个问题的标准做法是——干脆不用 <select>,用 <div> 和 <button> 自己搭一个。
第一次改造:div 模拟方案
我们做的第一版大概是这样:
<div class="account-switcher">
<button type="button" aria-haspopup="listbox" aria-expanded="false">
<img src="/avatar/zhang.png" alt="">
<span>张三</span>
<small>专业版</small>
<svg class="chevron"><!-- 箭头 --></svg>
</button>
<ul role="listbox" hidden>
<li role="option" aria-selected="true">
<img src="/avatar/zhang.png" alt="">
<span>张三</span>
<small>专业版</small>
</li>
<li role="option" aria-selected="false">
<img src="/avatar/li.png" alt="">
<span>李四</span>
<small>基础版</small>
</li>
</ul>
</div>
视觉上没问题。加上一堆 CSS 之后,跟产品给的图能有九成像。
但它带来了一串以前没意识到的麻烦:
键盘交互得全自己实现。方向键上下的移动、Home / End 跳到首尾、输入首字母快速定位、Esc 关闭并把焦点还回按钮——每一条都得写。我们那一版差不多写了 200 行 JS。
移动端体验明显变差。原生 <select> 在 iOS 上会弹出系统级别的选择器,滚轮手感是系统的、字号是系统的、深色模式是自适应的。换成 div 之后,那就是一个网页里的小弹窗,跟系统脱节。用户一上手就能感觉到「这个 app 的下拉框不太对」。
自动化测试也跟着变麻烦。以前测表单,直接 select.selectOption('1') 就完事了;现在 Playwright 得先点按钮、再点 li、再断言选中的是哪个,一次测试多了三四步操作,跑得慢了不少。
最要命的是表单。原生 select 是表单原生控件,提交的时候 value 会自动带上;我们这个是 div,得手动同步一个 <input type="hidden">,还得处理表单重置、自动填充、浏览器后退恢复状态这些边界情况。
这东西我们维护了两年多,中间改过七八次小问题。每次有人反馈「下拉框有点怪怪的」,我都得先判断是哪个环节的锅。
第二次改造:发现 base-select
去年年底翻 Chrome 的发布说明,看到一个叫 appearance: base-select 的东西。第一眼以为只是换皮,仔细看了才明白——它允许你在 <select> 内部放置本来被禁止的元素。
关键的两条规则:
第一,给 select 和它的 picker 都加上 appearance: base-select。picker 就是下拉出来的那个面板。
第二,可以在 <select> 里放一个 <button>,把原来被隐藏的原始按钮替换掉,然后在 option 里放任意元素。
<select name="account" id="account-select">
<button type="button">
<selectedcontent></selectedcontent>
<svg class="chevron" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4"/></svg>
</button>
<option value="zhang" selected>
<img src="/avatar/zhang.png" alt="" width="24" height="24">
<span>张三</span>
<small>专业版</small>
</option>
<option value="li">
<img src="/avatar/li.png" alt="" width="24" height="24">
<span>李四</span>
<small>基础版</small>
</option>
</select>
<selectedcontent> 是这套机制里的核心。它像一个占位槽——浏览器会自动把当前选中 option 的内容克隆过来填进去。也就是说,你在 option 里写的那份头像加名字加套餐,会被自动镜像到折叠状态下的按钮上。
这意味着你只需要维护一份内容。
更关键的是一点没有改变:它依然是一个真正的 <select> 元素。 表单提交、键盘导航、移动端原生选择器、可访问性树、自动化测试接口——全都和以前一模一样。
我们那 200 行 JS 键盘处理逻辑,全部删掉。
CSS 那边加了什么
切换需要的两行基础样式:
select,
select::picker(select) {
appearance: base-select;
}
不写这个,<button> 和 <selectedcontent> 会被当作不被识别的元素静默忽略,你看到的还是原生那个朴素的下拉框。
然后是布局。里面的元素此时已经可以用普通 CSS 去控制了:
select > button {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid #d4d4d8;
border-radius: 8px;
background: #fff;
min-width: 220px;
}
select > button img {
width: 24px;
height: 24px;
border-radius: 50%;
flex-shrink: 0;
}
select > button small {
margin-left: auto;
color: #71717a;
font-size: 12px;
}
select::picker(select) {
margin-block-start: 4px;
border: 1px solid #d4d4d8;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
padding: 4px;
}
select option {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
}
select option::checkmark {
display: none; /* 我们用背景色表示选中 */
}
select option:checked {
background: #f4f4f5;
}
注意 option 上可以直接用 flex 了,这是 base-select 带来的最直接的变化。以前给 option 加 display: flex 是不会起作用的。
::checkmark 是个新伪元素,代表原生那个选中打勾的标记。我们用它来关掉默认的勾选图标,改用背景色表达选中状态,视觉上更贴合产品图。
踩到的第一个坑:selectedcontent 是克隆,不是引用
我给头像加了 alt 文本,给 img 挂了一个 onerror 用于加载失败时替换成文字首字母。结果发现:折叠状态下点了头像,那个 onerror 没有触发。
查了一下才明白原因——<selectedcontent> 里显示的内容是浏览器对选中 option 子树的深层克隆,不是同一个节点,也不共享事件监听器。用 addEventListener 出来绑的那种监听器,克隆后的副本上是不存在的。
这个行为本身是合理的(克隆能保证状态隔离),但踩到的时候挺费解,因为 DOM 检查工具里看到的结构明明是一样的。
解决方式是把逻辑挪到父级,用事件委托:
document.querySelector('select').addEventListener('error', (e) => {
if (e.target.tagName === 'IMG') {
e.target.replaceWith(fallbackInitials(e.target.dataset.user));
}
}, true); // 用捕获阶段接住 error 事件
用捕获阶段是因为 error 事件不冒泡。
第二个坑:动态改 option 内容不会实时刷新
我们的头像 URL 有缓存破坏参数,用户上传新头像之后,前端要更新 option 里那个 img 的 src。第一版的代码是这样:
const option = select.querySelector('option[value="zhang"]');
option.querySelector('img').src = newUrl + '?v=' + Date.now();
改完之后打开下拉框,列表里的头像是新的没错;但折叠状态下的按钮上还是旧头像。
原因是 <select> 的按钮内容是在某些时机才重新同步的,改 option 子节点的属性不会立刻触发这个同步。解决办法是把 option 的整个内容替换掉:
const option = select.querySelector('option[value="zhang"]');
option.innerHTML = `
<img src="${newUrl}?v=${Date.now()}" alt="" width="24" height="24">
<span>张三</span>
<small>专业版</small>
`;
select.dispatchEvent(new Event('change')); // 强制刷新
派发一次 change 事件能让浏览器重新同步 selectedcontent 的内容。这个技巧有一点副作用——它也会触发业务侧监听 change 的逻辑。如果监听里有比较重的操作,可能得加一层标记绕过去。我们的做法是在事件对象上挂一个自定义属性作为标识:
const evt = new Event('change');
evt.__synthetic = true;
select.dispatchEvent(evt);
select.addEventListener('change', (e) => {
if (e.__synthetic) return;
// 真正的用户交互逻辑
});
有点绕,但目前没找到更干净的办法。
第三个坑:picker 的定位
默认状态下,::picker(select) 弹出的位置是浏览器自己算的——它会尽量贴近原控件,但如果下方空间不够会自动翻到上方。这个行为本身是好的。
问题是它默认有个 position: absolute,定位基准是最近的一个定位祖先。如果 select 放在一个 overflow: hidden 或者 transform 的容器里,弹出面板可能会被裁掉,或者跟着容器一起被缩放。
我们的账号切换器放在顶栏,顶栏恰好有一个 position: sticky。第一次测试的时候,面板弹出来是横向偏移的,跑到右边屏幕外面去了。
解决办法是显式指定固定定位:
select::picker(select) {
position: fixed;
top: anchor(bottom);
left: anchor(left);
margin-top: 6px;
}
这里用的是 CSS 锚点定位。select 自己天然就是一个锚点,不需要额外声明 anchor-name。anchor(bottom) 取的就是原控件的下边缘。
加上这个之后,弹出面板会像 popover 一样挂在顶层,不受祖先容器影响。
不过要注意一点,position: fixed 的面板在页面滚动时不会跟着走——它算出来的是打开那一刻的位置。所以如果用户在面板打开的状态下滚动了页面,面板会孤零零地留在原地。我们的处理是监听滚动时关掉面板:
window.addEventListener('scroll', () => {
const select = document.getElementById('account-select');
if (select && select.matches(':open')) {
select.hidePicker?.();
}
}, { passive: true });
hidePicker() 是这套 API 新加的方法,配套的还有 showPicker() 和 :open 伪类。后者用于判断当前 picker 是不是展开的,比起以前用 document.activeElement 去猜方便多了。
第四个坑:表单提交的值还是 value 属性
这一点其实不算坑,但当时确实让我愣了一下。
改造完之后提交表单,发现后端拿到的还是「zhang」「li」这样干净的值,而不是 <option> 里那一坨带着 img、span 的 HTML。
对的。表单提交取的是 option 的 value 属性,与里面的内容无关。这也正是它比 div 模拟方案好的地方——UI 和值是分离的,不用再维护那个 hidden input。
不过有一件事需要注意:如果 option 没有写 value,回退取的是它的文本内容。而文本内容现在包含了子元素里所有的文字——头像没有文字,但 span 和 small 会连在一起。所以每个 option 都显式写上 value,不要偷懒。
我们有一个 option 忘了写,提交上去变成了「张三专业版」,后端校验失败才发现。
第五个坑:自定义按钮里的图标会重复
我给 select 的按钮加了一个向右的小箭头,用的是 SVG:
<button type="button">
<selectedcontent></selectedcontent>
<svg class="chevron">...</svg>
</button>
期望的布局是左侧显示当前选中项,右侧显示箭头。
结果第一次渲染出来,箭头出现了两次,一左一右,很奇怪。仔细看 DOM 才发现——浏览器默认会在 select 按钮里也加一个箭头,用的是 ::picker-icon 伪元素。我们自己写的 SVG 加上去之后,就变成了两个箭头。
两种处理方式。要么去掉自己的 SVG,直接给 ::picker-icon 加样式;要么保留自己的 SVG,把原生的隐藏掉:
select::picker-icon {
display: none;
}
我选了后者,因为产品图里的箭头形状是定制的,而且希望它在 picker 展开时能翻转 180 度。这个动画用自己写的 SVG 更容易控制:
select:open::picker-icon,
select:open > button > svg {
transform: rotate(180deg);
transition: transform 200ms;
}
第六个坑:移动端的判断
在 iOS Safari 和 Android Chrome 上跑第一版的时候,看到的下拉感觉和原来一模一样——层叠式滚轮,系统样式。我一度以为出了 bug。
其实这是正常的。这些浏览器还不支持 base-select,所以整个 <select> 就走的是原生逻辑。你写在里面的 <button> 和 <selectedcontent> 会被忽略,option 里的 <img> 也不会显示,但 option 的文本内容会正常露出来——比如「张三专业版」。
这个降级结果是可以接受的。功能完整,只是视觉上不那么花哨。比起 div 模拟方案在移动端体验变差的问题,这已经是很大的改观了。
但有一个细节需要注意——我们的 CSS 里有一些选择器是针对内部的 <button> 和 <small> 写的,在不支持 base-select 的浏览器里,这些选择器会匹配不到任何元素,不会报错,但也不生效。所以如果你在没有特性检测的情况下给这些元素加了关键样式(比如隐藏某些默认元素),在不支持的浏览器上不会出问题——因为那些目标元素本身就是无效的,不会渲染。
渐进增强的写法
核心思路是:不确定用户浏览器是否支持时,给一个安全的默认,再在支持的时候增强。
@supports (appearance: base-select) {
select,
select::picker(select) {
appearance: base-select;
}
select > button { /* 自定义布局 */ }
select option { display: flex; }
}
/* 不支持的浏览器会走原生渲染,不需要 fallback 样式 */
JS 侧的检测可以这样写:
const supportsSelectedContent =
typeof HTMLSelectedContentElement !== 'undefined';
if (supportsSelectedContent) {
// 初始化头像加载失败的处理等
}
这个判断比检测 CSS 支持更可靠,因为它直接对应到”这个元素是不是真的有特殊行为”。只判断 CSS.supports('appearance', 'base-select') 的话,某些只实现了 base-select 样式解析、但没实现 selectedcontent 行为的版本会误判。
上线后的实测数据
打包体积的变化是意外的收获。删掉那 200 行键盘处理逻辑之后:
改造前 gzip 之后 7.4KB
改造后 gzip 之后 1.1KB
节省下来的主要是键盘导航、焦点管理、Form 提交同步这几块。这部分代码的复杂度其实一直比体积更让人头疼——过去两年里它一共被删改过 11 次,每次都是因为不同浏览器的行为差异。
自动化测试的稳定性也上来了。切换账号这个用例以前偶尔会因为焦点定位超时,现在用 Playwright 的 selectOption() 就能搞定:
await page.selectOption('#account-select', 'zhang');
await expect(page.locator('#account-select')).toHaveValue('zhang');
不再需要模拟两次点击加一次等待。
什么时候不该用它
如果你的团队还有相当比例的用户在 Safari 上,而且产品对视觉的要求很严格(不愿意接受 Safari 上就是朴素样式),那这套东西目前的适用性还不够。
Safari 目前还没实现 base-select,具体时间表也不明确。Firefox 那边情况类似。所以现在上这套方案,本质上是「Chrome 上终于能用原生方案做出来了,其他浏览器保持现状」。
如果你的项目只需要支持 Chrome 内核,那没什么好犹豫的,直接换。因为它完全是渐进增强的,不会对任何用户造成功能损失。
如果项目需要跨浏览器一致体验,那得先想清楚:你是不是已经为了这件事付出了很大的代价。如果现在的 div 模拟方案已经稳定运行得很久,改与不改,收益其实不如预期那么大。
最后
这次改造最让人意外的不是代码量减少了 85%,而是解决方式本身。过去几年,面对 select 的这些限制,我们的直觉一直是「那就别用 select,自己造一个」。慢慢地,造出来的东西背上了越来越多的负担,而我们已经不太记得最初是为了什么放弃原生方案的。
这次的经历让我重新意识到:面对平台上达不到的需求,第一反应不该是「绕开平台」,而应该是「先看看平台这几年有没有变」。很多时候,多年前挡路的那个限制,现在可能已经不在了。
产品验收那天,只留了一句「这看起来不错」。不过看到那 200 多行删掉的代码,我觉得折腾这一周挺值的。

